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.
145 lines
4.7 KiB
JavaScript
145 lines
4.7 KiB
JavaScript
/**
|
|
* C232 — thin OpenAI-compatible vision client for the photo-archive
|
|
* cataloguing: one POST to `{base}/chat/completions` with the image as a
|
|
* `data:` URL, plain `fetch` (injectable), timeout and retry with backoff for
|
|
* transient failures only. There is no SDK dependency and no vendor pinned:
|
|
* any local server speaking the protocol (Ollama, llama.cpp, vLLM) fits.
|
|
*
|
|
* The API key, when the server has one, travels in the `Authorization` header
|
|
* and never enters logs or receipts. A 4xx answer is terminal (a wrong model
|
|
* name must not be retried); 429/5xx/network/timeout retry.
|
|
*/
|
|
|
|
const DEFAULT_TIMEOUT_MS = 90_000
|
|
const DEFAULT_RETRY_ATTEMPTS = 3
|
|
const DEFAULT_RETRY_BASE_DELAY_MS = 2_000
|
|
const DEFAULT_MAX_OUTPUT_TOKENS = 900
|
|
const DEFAULT_TEMPERATURE = 0.2
|
|
|
|
export class ArchiveVisionError extends Error {
|
|
/**
|
|
* @param {string} message
|
|
* @param {{ status?: number | null }} [options]
|
|
*/
|
|
constructor(message, { status = null } = {}) {
|
|
super(message)
|
|
this.name = 'ArchiveVisionError'
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
/** HTTP 429/5xx, aborts and network errors retry; a 4xx refusal never does. */
|
|
const isRetryable = (error) => {
|
|
if (error instanceof ArchiveVisionError) {
|
|
return error.status === 429 || (error.status !== null && error.status >= 500)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* One vision call returning the raw assistant content (the caller parses it
|
|
* against the closed vocabularies). `imageDataUrl` is the prepared JPEG as a
|
|
* `data:image/jpeg;base64,…` string; the original never leaves the machine
|
|
* that prepared it.
|
|
*
|
|
* @param {{
|
|
* baseUrl?: string | null,
|
|
* model?: string | null,
|
|
* apiKey?: string | null,
|
|
* imageDataUrl: string,
|
|
* prompt: string,
|
|
* systemPrompt: string,
|
|
* fetchImpl?: typeof fetch,
|
|
* timeoutMs?: number,
|
|
* retryAttempts?: number,
|
|
* retryBaseDelayMs?: number,
|
|
* maxOutputTokens?: number,
|
|
* temperature?: number,
|
|
* sleep?: (ms: number) => Promise<void>,
|
|
* }} options
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export const analyzeArchivePhotoVision = async ({
|
|
baseUrl,
|
|
model,
|
|
apiKey = '',
|
|
imageDataUrl,
|
|
prompt,
|
|
systemPrompt,
|
|
fetchImpl = fetch,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
retryAttempts = DEFAULT_RETRY_ATTEMPTS,
|
|
retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS,
|
|
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
|
|
temperature = DEFAULT_TEMPERATURE,
|
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
}) => {
|
|
const base = String(baseUrl ?? '')
|
|
.trim()
|
|
.replace(/\/+$/, '')
|
|
if (base === '') throw new ArchiveVisionError('ARCHIVE_VISION_BASE_URL ausente.')
|
|
const chosenModel = String(model ?? '').trim()
|
|
if (chosenModel === '') throw new ArchiveVisionError('ARCHIVE_VISION_MODEL ausente.')
|
|
if (typeof imageDataUrl !== 'string' || !imageDataUrl.startsWith('data:image/')) {
|
|
throw new ArchiveVisionError('imagem preparada inválida (esperado data: URL).')
|
|
}
|
|
|
|
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }
|
|
const key = String(apiKey ?? '').trim()
|
|
if (key !== '') headers.Authorization = `Bearer ${key}`
|
|
|
|
const body = JSON.stringify({
|
|
model: chosenModel,
|
|
temperature,
|
|
max_tokens: maxOutputTokens,
|
|
stream: false,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: prompt },
|
|
{ type: 'image_url', image_url: { url: imageDataUrl } },
|
|
],
|
|
},
|
|
],
|
|
})
|
|
|
|
for (let attempt = 1; ; attempt += 1) {
|
|
try {
|
|
const response = await fetchImpl(`${base}/chat/completions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
})
|
|
if (!response.ok) {
|
|
// The provider's error body is operational gold ("model not found") —
|
|
// clipped into the receipt message; never the request, image or key.
|
|
let detail = ''
|
|
try {
|
|
detail = String(await response.text())
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 200)
|
|
} catch {
|
|
detail = ''
|
|
}
|
|
throw new ArchiveVisionError(`HTTP ${response.status}${detail ? `: ${detail}` : ''}`, {
|
|
status: response.status,
|
|
})
|
|
}
|
|
const parsed = await response.json().catch(() => {
|
|
throw new ArchiveVisionError('resposta do engine não é JSON.')
|
|
})
|
|
const content = parsed?.choices?.[0]?.message?.content
|
|
if (typeof content !== 'string' || content.trim() === '') {
|
|
throw new ArchiveVisionError('resposta do engine sem conteúdo de texto.')
|
|
}
|
|
return content
|
|
} catch (error) {
|
|
if (attempt >= retryAttempts || !isRetryable(error)) throw error
|
|
await sleep(retryBaseDelayMs * 2 ** (attempt - 1))
|
|
}
|
|
}
|
|
}
|