mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
Collection archivePhoto ganha o grupo catalog (legenda/descrição/cena/texto visível/temas/pessoas/município/origem/catalogedAt), curatedFields (o que a assessoria edita nunca é sobrescrito), takenOn e searchText com índice GIN trgm (migration aditiva add_archive_photo_catalog); hook beforeValidate deriva o índice e marca curadoria só em edição humana; pipeline transacional baixa os bytes privados, prepara JPEG <=1024px com sharp e mescla o VLM local com o gazetteer/catálogo curado (pessoas só do TEXTO, nunca de rosto); CLI pnpm archive:catalog (plan/--apply/--verify, recibo honesto, idempotente por catalogedAt) com engine OpenAI-compatible na rede privada atrás de guarda de host (ARCHIVE_VISION_ALLOW_REMOTE é o escape); client HTTP com retry/timeout e parser zod fail-closed; resolver de município extraído para owner compartilhado com o catálogo de peças delegando; unit/int novos e runbook staging-primeiro.
149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
ArchiveVisionError,
|
|
analyzeArchivePhotoVision,
|
|
} from '../../scripts/lib/archiveVisionApi.mjs'
|
|
|
|
// C232 — the vision client contract: request shape, auth header, transient
|
|
// retry, terminal 4xx, timeout/abort and the named refusals. `fetch` is always
|
|
// a fake — the real engine never enters the suite.
|
|
|
|
const jsonResponse = (body: unknown, status = 200) =>
|
|
({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => body,
|
|
text: async () => JSON.stringify(body),
|
|
}) as Response
|
|
|
|
const fakeFetch = (impl: (url: string, init?: RequestInit) => Promise<Response>) =>
|
|
vi.fn(impl) as unknown as typeof fetch
|
|
|
|
const call = (fetchImpl: typeof fetch, overrides: Record<string, unknown> = {}) =>
|
|
analyzeArchivePhotoVision({
|
|
baseUrl: 'http://100.94.122.26:11434/v1',
|
|
model: 'qwen2.5vl:7b',
|
|
imageDataUrl: 'data:image/jpeg;base64,AAAA',
|
|
prompt: 'descreva a foto',
|
|
systemPrompt: 'responda em JSON',
|
|
fetchImpl,
|
|
retryAttempts: 1,
|
|
sleep: async () => undefined,
|
|
...overrides,
|
|
})
|
|
|
|
describe('analyzeArchivePhotoVision (C232)', () => {
|
|
it('refuses to call without the engine config or a prepared image', async () => {
|
|
await expect(
|
|
call(
|
|
fakeFetch(async () => jsonResponse({})),
|
|
{ baseUrl: ' ' },
|
|
),
|
|
).rejects.toThrow(/ARCHIVE_VISION_BASE_URL/)
|
|
await expect(
|
|
call(
|
|
fakeFetch(async () => jsonResponse({})),
|
|
{ model: '' },
|
|
),
|
|
).rejects.toThrow(/ARCHIVE_VISION_MODEL/)
|
|
await expect(
|
|
call(
|
|
fakeFetch(async () => jsonResponse({})),
|
|
{ imageDataUrl: 'http://x/foto.jpg' },
|
|
),
|
|
).rejects.toThrow(/data: URL/)
|
|
})
|
|
|
|
it('posts the OpenAI-compatible shape with the image and the key header', async () => {
|
|
const fetchImpl = fakeFetch(async () =>
|
|
jsonResponse({ choices: [{ message: { content: '{"caption":"ok"}' } }] }),
|
|
)
|
|
|
|
const content = await call(fetchImpl, { apiKey: 'segredo' })
|
|
|
|
expect(content).toBe('{"caption":"ok"}')
|
|
const [url, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock
|
|
.calls[0]
|
|
expect(url).toBe('http://100.94.122.26:11434/v1/chat/completions')
|
|
expect(init.method).toBe('POST')
|
|
const headers = init.headers as Record<string, string>
|
|
expect(headers.Authorization).toBe('Bearer segredo')
|
|
const body = JSON.parse(String(init.body))
|
|
expect(body.model).toBe('qwen2.5vl:7b')
|
|
expect(body.stream).toBe(false)
|
|
expect(body.messages[0]).toMatchObject({ role: 'system', content: 'responda em JSON' })
|
|
expect(body.messages[1].content[0]).toMatchObject({ type: 'text', text: 'descreva a foto' })
|
|
expect(body.messages[1].content[1]).toMatchObject({
|
|
type: 'image_url',
|
|
image_url: { url: 'data:image/jpeg;base64,AAAA' },
|
|
})
|
|
})
|
|
|
|
it('retries a transient 5xx with backoff and succeeds', async () => {
|
|
const sleeps: number[] = []
|
|
let attempts = 0
|
|
const fetchImpl = fakeFetch(async () => {
|
|
attempts += 1
|
|
if (attempts === 1) return jsonResponse({ error: 'sobrecarregado' }, 503)
|
|
return jsonResponse({ choices: [{ message: { content: 'ok' } }] })
|
|
})
|
|
|
|
const content = await call(fetchImpl, {
|
|
retryAttempts: 3,
|
|
retryBaseDelayMs: 100,
|
|
sleep: async (ms: number) => {
|
|
sleeps.push(ms)
|
|
},
|
|
})
|
|
|
|
expect(content).toBe('ok')
|
|
expect(attempts).toBe(2)
|
|
expect(sleeps).toEqual([100])
|
|
})
|
|
|
|
it('does not retry a terminal 4xx refusal and keeps the provider detail', async () => {
|
|
const fetchImpl = fakeFetch(async () => jsonResponse({ error: 'modelo desconhecido' }, 404))
|
|
|
|
await expect(call(fetchImpl, { retryAttempts: 3 })).rejects.toMatchObject({
|
|
name: 'ArchiveVisionError',
|
|
status: 404,
|
|
message: expect.stringContaining('modelo desconhecido'),
|
|
})
|
|
expect((fetchImpl as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(1)
|
|
})
|
|
|
|
it('retries a network failure as transient', async () => {
|
|
let attempts = 0
|
|
const fetchImpl = fakeFetch(async () => {
|
|
attempts += 1
|
|
if (attempts === 1) throw new TypeError('fetch failed')
|
|
return jsonResponse({ choices: [{ message: { content: 'ok' } }] })
|
|
})
|
|
|
|
await expect(call(fetchImpl, { retryAttempts: 2 })).resolves.toBe('ok')
|
|
expect(attempts).toBe(2)
|
|
})
|
|
|
|
it('names an off-contract answer instead of guessing', async () => {
|
|
await expect(call(fakeFetch(async () => jsonResponse({ choices: [] })))).rejects.toBeInstanceOf(
|
|
ArchiveVisionError,
|
|
)
|
|
await expect(
|
|
call(
|
|
fakeFetch(
|
|
async () =>
|
|
({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => {
|
|
throw new Error('html')
|
|
},
|
|
}) as unknown as Response,
|
|
),
|
|
),
|
|
).rejects.toThrow(/não é JSON/)
|
|
})
|
|
})
|