Lição 22 · Fusão v3 · @alembic/ocr ← índice
Alembic × Hermes — O Curso de Fusão v3 · Percepção & Fábrica (21–25)

@alembic/ocr: o texto visível, determinístico por padrão

O seam de document-parsing do motor: alembic ocr <img> transforma um screenshot/página escaneada em texto — offline e determinístico por default ($0), e com --online fala o contrato HTTP EXATO do infer.py do Unlimited-OCR contra um servidor SGLang local. O mesmo desenho da lição 21: um kernel de despacho com Zod nas DUAS bordas, backends injetados, e um seam de text-artifacts que dá dentes ao gate de vídeo do marketing.

1

O contrato: exatamente UMA fonte de imagem

packages/ocr/src/types.ts espelha o types.ts de @alembic/embeddings "1:1" (auto-descrição em types.ts:12–16): um schema Zod de request, um de resultado, e um port de chamada única cuja saída não-confiável é re-validada na borda. O request aceita imagePath OU imageBase64 — e o .refine do schema impõe o XOR: "provide exactly one of imagePath or imageBase64" (types.ts:40–52). Um request ambíguo ou vazio morre ANTES de tocar qualquer backend.

O resultado é { text, blocks?, lang?, pages? } (types.ts:90–99): text pode ser vazio — "a blank page is a valid parse" (types.ts:91) — e blocks é OPCIONAL porque o Unlimited-OCR emite Markdown, não caixas por palavra (types.ts:58–62); quando presentes, cada bloco exige texto não-vazio e bbox de exatamente 4 números finitos não-negativos (types.ts:63–78). Sucesso/falha NÃO são campos: colapsam no Result, "so a failure never masquerades as empty text" (types.ts:88–89).

harness · 0004-saida-estruturada-ferramentas · s0

Pense como… a recepção de um cartório: ou você entrega o documento físico (path) ou uma cópia autenticada (base64) — nunca os dois, nunca nenhum. O atendente confere a entrada ANTES de carimbar e confere o carimbo ANTES de devolver. Onde quebra: aqui até a página em branco recebe protocolo (text: '' é válido) — o que não existe é protocolo falso para falha.

OcrRequest path XOR base64 safeParse ocr.ts:39–42 2 fontes / 0 fontes ⇒ err na borda backend(req) INJETADO — ocr.ts:44 ocrResultSchema re-valida — ocr.ts:47–50 bloco com texto vazio, bbox de 3 números… ⇒ err — saída malformada NUNCA passa
ocr.ts:35–52 — o mesmo abraço de duas bordas da lição 21, agora em chamada única (o OCR não é batch-nativo).
2

O default: offline, $0, mesmo input ⇒ mesmo output

createOfflineOcrBackend (packages/ocr/src/offline-backend.ts) devolve OCR(${source}) — um placeholder estável derivado só da fonte da imagem (offline-backend.ts:41), com blocks ecoando o texto e pages: 1 (offline-backend.ts:42–47). Para inline base64, a "fonte" vira um fingerprint hex de 32 bits calculado por um fold polinomial puro (offline-backend.ts:75–81) — imagens distintas ⇒ labels distintos, sem ecoar o blob gigante e sem crypto.

Na CLI, esse é o caminho de alembic ocr <imagePath> sem flags (apps/cli/src/commands.ts:4692–4693): recognition de verdade NÃO acontece — o que você ganha é wiring, cache e plumbing testáveis a custo zero, "the engine's --offline path" (offline-backend.ts:3–5). O texto do arquivo é direto: "The text is NOT a real recognition" (offline-backend.ts:12).

Por que isso importa: a doutrina offline-first da casa (o funil noturno hermético $0 do CLAUDE.md) exige que TODO pacote com backend pesado tenha um irmão determinístico. O OCR segue à risca: nada de Math.random()/Date.now() (offline-backend.ts:8–10 — a plan-VM proíbe ambos), então o mesmo request produz o MESMO resultado para sempre.
3

--online: o contrato de wire copiado verbatim do infer.py

createSglangOcrBackend (packages/ocr/src/sglang-backend.ts) é um fetch fino sobre o endpoint OpenAI-compatível /v1/chat/completions do servidor SGLang do Unlimited-OCR. O diferencial é a disciplina de reverse-engineering: o cabeçalho do arquivo CITA o contrato exato, linha a linha, do infer.py original — payload com temperature: 0, skip_special_tokens: false, stream: true, images_config.image_mode: "gundam" e o prompt "document parsing." (sglang-backend.ts:14–31; o body montado em :204–225).

E é offline-first até quando é o backend online: sem baseUrl configurada, devolve um err tipado SEM NENHUMA tentativa de rede (sglang-backend.ts:133–141). Com ela, todo fracasso de transporte vira err — throw de rede, status non-2xx (:244–246), body não-JSON (:301–308). A resposta é parseada nos DOIS formatos que o servidor pode falar: SSE streamado (concatena os deltas data: até data: [DONE], pulando chunks malformados exatamente como o infer.py faz — :278–291) ou JSON único (choices[0].message.content:294–298).

baseUrl? :133–141 err SEM tentativa de rede o default hermético $0 POST do infer.py temp 0 · stream · gundam data:<mime>;base64 — :204–225 tem linha data:? parseBody :259–266 SSE deltas até [DONE] :278 JSON único choices[0].message :294–298 throw de rede · HTTP non-2xx (:244–246) · body não-JSON (:301–308) ⇒ TUDO err — nunca exceção pela borda
sglang-backend.ts — fail-closed dos dois lados: sem config, nem rede; com config, todo transporte ruim vira err tipado.
apps/cli/src/commands.ts:4676–4691 (o gate do --online, trecho real)
if (args.online) {
  const baseUrl = process.env.ALEMBIC_OCR_BASEURL;
  if (baseUrl === undefined || baseUrl.length === 0) {
    return err(new Error(
      'ocr --online needs the Unlimited-OCR SGLang server (set ALEMBIC_OCR_BASEURL, ' +
      'e.g. http://127.0.0.1:10000; see docs/ocr-setup.md). No base URL set, so no ' +
      'network attempt was made. Omit --online for the deterministic offline backend.'));
  }
  backend = createSglangOcrBackend({ baseUrl, apiKey: process.env.ALEMBIC_OCR_TOKEN });
} else {
  backend = createOfflineOcrBackend(/* lang opcional */);
}
Você roda alembic ocr foto.png --online sem ALEMBIC_OCR_BASEURL no ambiente. O que acontece?
Correto: o gate está na CLI (commands.ts:4677–4687) e repete no backend (sglang-backend.ts:133–141) — sem base URL, err explícito SEM rede. Não há fallback silencioso (você pediu online; degradar escondido mentiria) nem default de porta (nada de URL hardcoded).
4

O seam de text-artifacts do marketing validate

produto · 0006-marketing-factory · s4

No gate de QA de vídeo, a pergunta "tem texto QUEIMADO nos frames?" é respondida por OCR — via o mesmo padrão de port da lição 21: FrameOcr (packages/marketing-factory/src/validate.ts:45–47), default noopFrameOcr que devolve '' (validate.ts:174–176). A CLI injeta os dentes reais com createFrameOcr sobre @alembic/ocr (apps/cli/src/marketing-seams.ts:34–41).

O fluxo: extractFrames amostra o mp4 com ffmpeg (validate.ts:399) na taxa frameRate default de 1 frame/segundo (validate.ts:110–111); scanFrames roda o OCR em cada frame e coleta TextArtifact { frame, text } para todo texto não-vazio (validate.ts:443–460). A varredura é OPT-IN — checkFrames = noBakedText || forbiddenVisuals.length > 0 (validate.ts:518) — e noBakedText default false, porque "many ad types legitimately use on-screen text/kinetic typography" (validate.ts:105–109): com a trava ligada, artefatos derrubam o gate (failures: 'text-artifact: N frame(s)…', validate.ts:579–580); desligada, são só informativos no relatório. O veredito final é pass = failures.length === 0 (validate.ts:593).

PeçaOndePapel
FrameOcr (port)validate.ts:45–47a FORMA de "leia este frame"
noopFrameOcrvalidate.ts:174–176default honesto: '' — sem fingir cobertura
createFrameOcrmarketing-seams.ts:34–41os dentes reais, injetados pela CLI
extractFramesvalidate.ts:399ffmpeg amostra 1 fps (default)
--no-textvalidate.ts:105–109opt-in: artefato ⇒ falha do gate
Um anúncio usa tipografia cinética de propósito. Rodando marketing validate SEM --no-text, o texto na tela…
Correto: noBakedText é opt-in (default false, validate.ts:105–109) exatamente porque texto na tela é legítimo em muitos formatos. Nota da opção b: os frames ainda PODEM ser amostrados se forbiddenVisuals estiver presente (checkFrames, :518) — as duas varreduras compartilham a amostragem.
CAMADA TÉCNICA — comandos copy-paste

Tudo $0 e read-only (o --online só é mostrado na forma que FALHA FECHADO sem servidor):

# o XOR de fonte única + o resultado com blocks/bbox
sed -n '40,52p;63,99p' packages/ocr/src/types.ts

# o kernel de duas bordas
sed -n '35,52p' packages/ocr/src/ocr.ts

# OCR offline determinístico, $0 — rode duas vezes e compare
alembic ocr ~/qualquer/imagem.png --json
alembic ocr ~/qualquer/imagem.png --json   # byte-idêntico

# o contrato verbatim do infer.py dentro do backend sglang
sed -n '14,31p;204,225p' packages/ocr/src/sglang-backend.ts

# prova do fail-closed do --online sem servidor (nenhuma rede é tentada)
env -u ALEMBIC_OCR_BASEURL alembic ocr ~/qualquer/imagem.png --online

# o seam de text-artifacts no gate de vídeo
sed -n '45,47p;105,111p;174,176p;443,473p' packages/marketing-factory/src/validate.ts
sed -n '29,41p' apps/cli/src/marketing-seams.ts
O que levar desta lição
Pergunta de acompanhamento sugerida: "por que o OCR é chamada única enquanto o vision é batch-nativo?" — compare o custo de carregar um VLM local vs falar HTTP com um servidor que JÁ está de pé. Próxima lição: os dois seams se encontram no produto — a fábrica de marketing multi-tenant inteira, das pastas de cliente à dupla trava de gasto.