From 00e393882f9cc3884bcfe7d2ee1a5d2f2e74471c Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 22:48:10 -0300 Subject: [PATCH] =?UTF-8?q?fix(C192/S28/C219):=20busca=20por=20tema=20indi?= =?UTF-8?q?sponivel=20=E2=80=94=20thinking=20do=20deepseek-flash=20truncav?= =?UTF-8?q?a=20o=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +- .../2026-09-24-busca-por-tema-thinking.md | 1 + docs/ops/teqo-1313-deploy.md | 32 ++++++++ .../2026-09-24-busca-por-tema-indisponivel.md | 80 ++++++++++++++++++ scripts/deploy-homeserver.sh | 34 ++++++++ src/utilities/ai/campaignDemandTitle.ts | 7 +- src/utilities/ai/deepseekFlashDefaults.ts | 14 ++++ src/utilities/ai/expandSpeechSearchTheme.ts | 8 +- src/utilities/ai/rerankSpeechExcerpts.ts | 9 ++- .../content/contentPieceCataloging.ts | 8 +- src/utilities/speech/speechCutMetadata.ts | 8 +- tests/unit/campaignDemandTitle.unit.spec.ts | 59 ++++++++++++++ .../unit/contentPieceCataloging.unit.spec.ts | 39 ++++++++- tests/unit/deployScript.unit.spec.ts | 28 +++++++ .../unit/expandSpeechSearchTheme.unit.spec.ts | 13 +++ tests/unit/rerankSpeechExcerpts.unit.spec.ts | 13 +++ tests/unit/speechCutMetadata.unit.spec.ts | 81 +++++++++++++++++++ 17 files changed, 429 insertions(+), 12 deletions(-) create mode 100644 docs/changelog/2026-09-24-busca-por-tema-thinking.md create mode 100644 docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md create mode 100644 src/utilities/ai/deepseekFlashDefaults.ts create mode 100644 tests/unit/campaignDemandTitle.unit.spec.ts create mode 100644 tests/unit/speechCutMetadata.unit.spec.ts diff --git a/.env.example b/.env.example index 48a18e53..42c19d8f 100644 --- a/.env.example +++ b/.env.example @@ -56,8 +56,11 @@ CAMPAIGN_EMAIL_FROM_NAME=Campanha Jorge Solla # Note: the automated test suite ignores this file and reads .env.test instead, # and only ever runs against a database whose name ends with "_test". -# DeepSeek API key for the AI chat feature (Sollinha) — the LLM talks directly -# to api.deepseek.com (cache-hit input is ~6x cheaper than via Deep Infra). +# DeepSeek API key for the AI-backed features (Sollinha chat, theme search, +# demand titles, cut/piece metadata) — the LLM talks directly to +# api.deepseek.com (cache-hit input is ~6x cheaper than via Deep Infra). +# The app containers read it at runtime: without it, every feature above +# degrades silently to its fallback (runbook: "capacidades de IA"). # Get one at https://platform.deepseek.com/api_keys DEEPSEEK_API_KEY=YOUR_DEEPSEEK_API_KEY_HERE diff --git a/docs/changelog/2026-09-24-busca-por-tema-thinking.md b/docs/changelog/2026-09-24-busca-por-tema-thinking.md new file mode 100644 index 00000000..1d4ed8e3 --- /dev/null +++ b/docs/changelog/2026-09-24-busca-por-tema-thinking.md @@ -0,0 +1 @@ +**Busca por tema indisponível — thinking do `deepseek-flash` truncava o JSON e a chave nunca chegou ao runtime (2026-09-24):** o aviso "A busca por tema está indisponível agora." era o estado **permanente** da feature em produção: os containers (`teqo-1313`/`teqo-staging`) nunca receberam `DEEPSEEK_API_KEY` no runtime (o `~/stack/.env` do homeserver tem a chave, mas o compose lê só o `env_file` do app), e **com** chave válida ~50% das chamadas reais degradavam — o `deepseek-flash` gasta o teto de saída com tokens de raciocínio (medido no provedor real: 93–354 por chamada) e os tetos antigos (120–500) truncavam o payload, com o `catch` silencioso devolvendo `null`. Medição por call site: tema 5/10 falhas (teto 200), rerank 4/4 `null` (400), metadados de corte 2/4 fallback (400), título de demanda 3/4 fallback (120). **Fix no dono único:** novo `src/utilities/ai/deepseekFlashDefaults.ts` (`thinking: disabled` + teto 1000) consumido pelos cinco call sites de saída estruturada — `expandSpeechSearchTheme.ts` (tema, 3 corpora), `campaignDemandTitle.ts`, `rerankSpeechExcerpts.ts`, `speechCutMetadata.ts` e `contentPieceCataloging.ts`; nenhum contrato de API/produto mudou (mesmo `catch`→fallback, só deixa de truncar). **Prova real pós-fix:** tema 10/10 temas com termos (0,9–2,1s), rerank 4/4 escolhas com motivo (antes 0/4), corte 4/4 `source: 'ai'`, título 4/4, catalogação 2/2 `source: 'ai'`, todos dentro dos timeouts 4–8s. **Regressão:** pin em cada dono (5 specs unit, 2 arquivos novos) afirmando thinking desligado + teto ≥1000 — RED sem o fix (`providerOptions` undefined) e GREEN com ele; `pnpm gate:fast` verde (422 arquivos/4677 unit, +9) e os 7 int da superfície (`contentPiece`, `speechAcervo`, `recording`, `webSpeechAcervo`, `speechCut`, `speechExcerpts`, `campaignDemandWorkflow`) verdes (136 passed). **Prevenção de ops:** `scripts/deploy-homeserver.sh` audita o env do container após o healthcheck e emite `say` + `::warning::` para chave de feature ausente (`DEEPSEEK_API_KEY`, `DEEPINFRA_API_KEY`, `RESEND_API_KEY`+`CAMPAIGN_EMAIL_FROM`, `VAPID_PUBLIC_KEY`+`VAPID_PRIVATE_KEY`) — **não fatal** (quais chaves existem é decisão humana), pinado em `deployScript.unit.spec.ts`; runbook ganhou a seção "Busca por tema e demais capacidades de IA — envs do app" com o copia-e-cola idempotente de `~/stack/.env` para os env files de produção/staging. **Pendência humana:** rodar o copia-e-cola no homeserver antes/junto do approve da produção (o container só ganha a chave na recriação do deploy) e confirmar a busca por tema em prod — sem a chave o aviso continua 100% legítimo. Post-mortem: `docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md`. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index ed7137eb..1cae3dd1 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -466,6 +466,38 @@ Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o passo). +## Busca por tema e demais capacidades de IA — envs do app + +As chamadas de IA do app (busca por tema do acervo e da Central, título de +demanda, metadados de corte, catalogação de peças, reranking do Sollinha e +transcrição de voz) rodam no **runtime do container** e leem as chaves do +`env_file` do compose (`~/stack/teqo-1313.env` / `~/stack/teqo-staging.env`). +O `~/stack/.env` do homeserver (que o `deploy-homeserver.sh` sourceia para +registry e BuildKit secrets) **não** chega ao runtime. Chave ausente não +derruba o boot: a capacidade degrada em silêncio — a busca por tema mostra +"indisponível agora" e o título cai no fallback determinístico (incidente +2026-09-24; post-mortem em +`docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md`). + +Pré-requisitos de produção e staging: `DEEPSEEK_API_KEY` (busca por tema, +Sollinha, título/metadados) e `DEEPINFRA_API_KEY` (transcrição de voz). O +`~/stack/.env` do homeserver já tem as duas; copie as linhas para o env file +do app (idempotente): + +```bash +ssh homeserver +for f in ~/stack/teqo-1313.env ~/stack/teqo-staging.env; do + grep -q '^DEEPSEEK_API_KEY=' "$f" || grep '^DEEPSEEK_API_KEY=' ~/stack/.env >> "$f" + grep -q '^DEEPINFRA_API_KEY=' "$f" || grep '^DEEPINFRA_API_KEY=' ~/stack/.env >> "$f" +done +``` + +O deploy seguinte recria o container com as chaves. Desde o incidente, o +`deploy-homeserver.sh` audita o env do container recém-saído do healthcheck e +anota no run (`::warning::`) qualquer chave de feature ausente — não falha o +deploy (quais chaves existem é decisão humana), mas a perda de capacidade +aparece no próprio run. + ## OPS79 — última migração da plataforma antiga → nova (vertical campanha) Operação de dados executada em 2026-08-23. Ver assistência lógica completa: diff --git a/docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md b/docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md new file mode 100644 index 00000000..ca969db0 --- /dev/null +++ b/docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md @@ -0,0 +1,80 @@ +# Post-mortem: busca por tema indisponível — thinking do `deepseek-flash` truncava o JSON e a chave nunca chegou ao runtime + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Data do post-mortem | 2026-09-24 | +| Severidade | alta (cinco capacidades de IA degradadas em silêncio em prod; nenhum dado perdido) | +| Ambiente | prod (e staging — mesma config) | +| Issue(s) | sem Issue — relato do humano na sessão do worktree `fix/11` (o bug-fix não exige Issue; o post-mortem é o registro) | +| PR do fix | este PR | +| Detectado por | humano | + +## Timeline + +| Momento | Data/hora | Evento | +| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Início provável (ops) | 2026-08-17/18 (OPS51/OPS52) | O cutover Vercel → homeserver moveu o app para o compose do homeserver; o `DEEPSEEK_API_KEY` (que na era Vercel vivia no project env) não entrou no `~/stack/teqo-1313.env` — ficou só no `~/stack/.env`, que o compose do app não lê. As capacidades de IA passaram a degradar em 100%. | +| Início provável (código) | 2026-09-18 (`45eef25b`, C192) | A busca por tema nasce chamando `generateObject` do `deepseek-flash` com `maxOutputTokens: 200` e thinking default; S28 (2026-09-23) reusa o mecanismo e C219 (2026-09-24, `38e67426`) adiciona o 3º corpus — mesma config. Os tetos irmãos (120/400/500) nasceram junto dos seus donos. | +| Detecção | 2026-09-24 (sessão `fix/11`) | Humano relata "A busca por tema esta indisponivel." — o aviso do modo `tema` no acervo interno (`/campanha/comunicacao/acervo`). | +| Diagnóstico | 2026-09-24 | Read-only em prod: containers `teqo-1313`/`teqo-staging` **sem** `DEEPSEEK_API_KEY`/`DEEPINFRA_API_KEY`/`RESEND_API_KEY`/`VAPID_*` no env (nomes via `docker inspect`); `~/stack/.env` **tem** `DEEPSEEK_API_KEY` + `DEEPINFRA_API_KEY`; os env files do app não. Smoke real do módulo com a chave da estação: 5/10 falhas com `AI_NoObjectGeneratedError` (JSON truncado / resposta vazia) no teto 200; `usage` medido: 93–354 tokens de raciocínio contra 28–34 de texto. Com `thinking: disabled`: 6/6. | +| Correção mergeada | a definir (este PR) | — | +| Deploy | a definir | deploy começa no merge; produção só após approve humano no environment `production` | +| Verificado em prod | pendente (aguarda o humano) | pendente — e só vale depois de a chave entrar no `~/stack/teqo-1313.env` e o container ser recriado | + +## O bug + +No acervo interno de comunicação, o modo **"Por tema"** (`?q=…&mode=tema`) exibia permanentemente o aviso "A busca por tema está indisponível agora." e caía na busca literal. Na Central pública (`/conteudos`), a mesma degradação existiria assim que houvesse peças publicadas — hoje a produção mostra o vazio honesto "As primeiras peças estão a caminho" (zero peças publicadas, verificado read-only no navegador). Quem afetava: a assessoria (acervo de falas/gravações/internet) e o eleitor (Central) — sem perda de dado: o fallback determinístico sempre respondeu. + +O aviso é o contrato **projetado** de degradação do modo tema (o mecanismo é um LLM externo); o bug é ele estar sempre aceso por dois motivos independentes — chave ausente no runtime (100%) e truncamento do JSON com chave válida (~50%). + +## Causa-raiz + +**Camada 1 — runtime sem a chave (100% dos casos em prod).** Os containers são criados pelo compose (`~/stack/docker-compose.yml`, fora do repo) com `env_file: ./teqo-1313.env` / `./teqo-staging.env`; o `scripts/deploy-homeserver.sh:121-124` sourceia **também** `~/stack/.env`, mas usa esse arquivo só para registry/BuildKit — o runtime do container não o vê. O `~/stack/.env` do homeserver tem `DEEPSEEK_API_KEY` e `DEEPINFRA_API_KEY` (verificado: nomes, nunca valores), os env files do app não. Ninguém percebeu porque `expandSpeechSearchTheme.ts:108` (`if (!process.env.DEEPSEEK_API_KEY) return null`) é fail-safe: a ausência vira o mesmo `null` de um erro transitório e o aviso continua honesto sobre a capacidade, mentiroso sobre a causa. + +**Camada 2 — thinking consumia o teto de saída (com chave, ~50%).** `expandSpeechSearchTheme.ts` chamava `generateObject` (`deepseek-flash`) com `maxOutputTokens: 200` e thinking default. Medido no provedor real: o modelo gasta 93–354 tokens de **raciocínio** antes do texto (o JSON custa 28–49); quando o raciocínio não cabe no teto, o JSON chega truncado (`AI_JSONParseError: ... Unterminated string`) ou não chega (`the model did not return a response`) → `catch { return null }` → aviso. O mesmo defeito de configuração estava em quatro irmãos: `campaignDemandTitle.ts` (teto 120; medido 3/4 títulos reais `null`), `rerankSpeechExcerpts.ts` (400; medido 4/4 `null`), `speechCutMetadata.ts` (400; medido 2/4 fallback) e `contentPieceCataloging.ts` (500). Por que passou invisível: unit mocam o provedor, o e2e **zera** `DEEPSEEK_API_KEY` de propósito (`playwright.config.ts:287`) e o smoke real com o provedor estava registrado como pendente desde C158/C192 (`acervo-busca-semantica-impl.md`, "validar no primeiro uso em staging/produção") — nunca aconteceu até este incidente. + +**5-whys:** aviso → `null` silencioso → JSON truncado (thinking + teto baixo) **ou** chave ausente → config de provedor nunca validada ao vivo + chave nunca provisionada no runtime → nenhum contrato "chave esperada em prod" (runbook/guard) e o CI não fala com o provedor. + +## Correção + +1. **Dono único das defaults:** novo `src/utilities/ai/deepseekFlashDefaults.ts` — `DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS` (`{ deepseek: { thinking: { type: 'disabled' } } }`) e `DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS = 1000`. +2. **Cinco call sites passam a consumi-lo** (o dono de cada feature): `expandSpeechSearchTheme.ts` (tema, 3 corpora), `campaignDemandTitle.ts`, `rerankSpeechExcerpts.ts`, `speechCutMetadata.ts` e `contentPieceCataloging.ts`. Contratos de API/produto intocados: os guards (`!key` → fallback), timeouts, validações e `catch`→fallback seguem idênticos — o que muda é que o payload deixa de truncar. +3. **Ops:** o runbook (`docs/ops/teqo-1313-deploy.md`) ganhou a seção "Busca por tema e demais capacidades de IA — envs do app" com o copia-e-cola idempotente de `~/stack/.env` para os env files; a chave só chega ao container na recriação (deploy). + +Nenhuma migration, nenhum Consent/access, nenhum contrato de URL/shape público. + +## Verificação + +- Teste de regressão: `desliga o thinking e mantém teto de saída acima do orçamento de raciocínio` em 5 specs unit — **RED** no estado pré-fix (o pin do tema falhou com `providerOptions: undefined` na primeira execução) e **GREEN** com o fix (`expandSpeechSearchTheme`, `rerankSpeechExcerpts`, `contentPieceCataloging`, `speechCutMetadata`, `campaignDemandTitle`). +- Provedor real (chave da estação, nunca impressa): tema 10/10, rerank 4/4 (antes 0/4), corte 4/4 `source: 'ai'` (antes 2/4), título 4/4 (antes 1/4), catalogação 2/2 `source: 'ai'` — 0 falhas. +- Suíte: `pnpm gate:fast` verde (lint, `tsc --noEmit`, 422 arquivos/4677 unit, +9); `pnpm format:check` verde; int da superfície (7 arquivos: `contentPiece`, `speechAcervo`, `recording`, `webSpeechAcervo`, `speechCut`, `speechExcerpts`, `campaignDemandWorkflow`) 136 passed/1 skipped; e2e curados (`campaignAiTranscribe`, `campaignSpeechAcervo`) verdes no relevante (2 flakes de fixture rerodados isolados verdes — pré-existentes de ordem). +- Guard de ops: `bash -n` verde; `deployScript.unit.spec.ts` verde (22) pinando o audit e a ordem (healthcheck → audit → smoke); audit exercitado com `docker` stubado (env com buraco → `::warning::` com a lista exata; tudo presente → silêncio). +- CI: verde no PR (a preencher) +- Prod: pendente — deploy começa no merge; produção só após approve humano; a validação do humano depende de a chave entrar no `~/stack/teqo-1313.env` (copia-e-cola do runbook) e o container ser recriado. + +## Prevenção + +| Estratégia | Custo | Estado | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- | +| Pin de regressão por dono (thinking off + teto ≥1000) e dono único das defaults (`deepseekFlashDefaults.ts`) | barata | implementada agora (este PR) | +| Audit não-fatal de feature envs no deploy (lê o env do container pós-healthcheck, emite `say` + `::warning::`) + pin em `deployScript.unit.spec.ts` + seção no runbook | barata | implementada agora (este PR) | +| Helper único do boilerplate DeepSeek (`generateObject`/`generateText` + guard de chave + timeout + `catch`→`null`), débito já registrado em `docs/plans/acervo-busca-semantica-impl.md:153` | cara | documentada — não implementada neste fluxo | +| Falhar o deploy quando uma feature env esperada está ausente | cara | documentada — muda contrato de ops (pode travar hotfix/rollback) e é decisão humana | +| Observabilidade de degradação (contador/alerta por `catch`→`null` de cada feature, em vez de só o aviso na UI) | cara | documentada — instrumentação nova | +| Passar as chaves de `~/stack/.env` ao runtime via `environment:` no compose (infra-solla), em vez de copiar linhas | cara | documentada — toca repo de infra fora deste repo | + +**Estratégia implementada:** os cinco pins de regressão travam a configuração do provedor em cada call site (o defeito não volta sem um teste vermelho), o dono único impede que um irmão fique para trás num próximo ajuste, e o audit de deploy transforma "capacidade degradada em silêncio" em anotação visível no run — exatamente o que faltou para o incidente aparecer em 2026-08-17. + +**Estratégia documentada (cara):** o helper único (`:153`) absorveria guard/timeout/catch/defaults dos cinco donos e é o candidato natural a Issue; tornar feature env ausente fatal no deploy e a instrumentação de degradação são decisões de produto/ops maiores; a passagem das chaves pelo compose elimina o copia-e-cola mas vive no repo de infra. + +## Lições + +- **Fallback silencioso é fail-safe para o usuário e fail-blind para a operação.** O aviso "indisponível agora" era verdadeiro sobre a capacidade e mudo sobre a causa (transitório? cota? configuração?) — ninguém tinha como saber que era config desde 17/08 sem inspecionar o container. +- **Mock não sente o provedor.** Unit mocando `generateObject` e e2e com a chave zerada de propósito passaram por 10 dias de código e 5 features quebradas; o único smoke que pegaria era o real — registrado como pendente desde C158/C192 e nunca executado. Um smoke real barato (uma chamada) no primeiro uso em staging teria pegado os 50% de truncamento. +- **Cutover de infra pode derrubar capacidade sem mudar uma linha de código.** A chave existia na era Vercel; a migração para o homeserver levou o app e não a chave do runtime — o CI, o lint e os testes não têm como ver isso; só um contrato explícito de envs esperadas no deploy vê. +- **Teto de saída é orçamento compartilhado com o raciocínio.** Em modelo com thinking default, `maxOutputTokens` pequeno não limita o tamanho da resposta — trunca o pensamento; a resposta correta é desligar o thinking em tarefas de transformação determinística e/ou dar margem medida, não apertar o teto. +- **Tarefas iguais pedem dono.** A mesma config repetida em cinco call sites garantiu que o incidente atingisse cinco features de uma vez; a defaults compartilhada fecha o vetor de drift até o helper completo chegar. diff --git a/scripts/deploy-homeserver.sh b/scripts/deploy-homeserver.sh index c76a3c9f..c3344bbd 100644 --- a/scripts/deploy-homeserver.sh +++ b/scripts/deploy-homeserver.sh @@ -276,6 +276,40 @@ for _ in $(seq 1 30); do done [ "$health" = "healthy" ] || rollback "container not healthy after 300s (status: $health)" +# --- feature env audit (non-fatal) -------------------------------------- +# A missing feature key never fails the boot: the capability degrades +# silently (AI theme search/titles/transcription, forgot-password e-mail, +# push). The audit reads the RUNNING container env — the compose env_file is +# the source of truth, not what the script sourced — and annotates what is +# missing so the operator sees the capability loss in this very deploy. It +# never fails the deploy: which feature keys are set is the human's call. +# The list mirrors the app predicates (`isCampaignEmailConfigured`, +# `resolveVapidConfig` and the DeepSeek/Deep Infra guards). + +FEATURE_ENV_KEYS=( + DEEPSEEK_API_KEY + DEEPINFRA_API_KEY + RESEND_API_KEY + CAMPAIGN_EMAIL_FROM + VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY +) + +warn_missing_feature_envs() { + local container_env missing=() name + container_env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$TEQO_CONTAINER" 2>/dev/null || true)" + for name in "${FEATURE_ENV_KEYS[@]}"; do + printf '%s\n' "$container_env" | grep -q "^${name}=." || missing+=("$name") + done + if [ "${#missing[@]}" -gt 0 ]; then + say "WARNING: $TEQO_CONTAINER is missing feature envs: ${missing[*]}" + printf '::warning::%s is missing feature envs: %s (those capabilities degrade silently)\n' \ + "$TEQO_CONTAINER" "${missing[*]}" + fi +} + +warn_missing_feature_envs + # --- smoke -------------------------------------------------------------- base="$TEQO_SMOKE_BASE" diff --git a/src/utilities/ai/campaignDemandTitle.ts b/src/utilities/ai/campaignDemandTitle.ts index 27b19971..6488b9aa 100644 --- a/src/utilities/ai/campaignDemandTitle.ts +++ b/src/utilities/ai/campaignDemandTitle.ts @@ -5,6 +5,10 @@ import { generateText } from 'ai' import { isUsableDemandTitle } from '@/lib/demandTitle' import { campaignDemandKindLabels, type CampaignDemandKind } from '@/lib/schemas/campaignDemand' +import { + DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, +} from '@/utilities/ai/deepseekFlashDefaults' const DEMAND_TITLE_SYSTEM_PROMPT = 'Você resume pedidos de campanha eleitoral em títulos curtos e objetivos para uma lista de demandas. ' + @@ -34,7 +38,8 @@ export const deriveDemandTitle = async ( ? `Tipo: ${campaignDemandKindLabels[kind]}\nPedido: ${description}` : description, temperature: 0.3, - maxOutputTokens: 120, + maxOutputTokens: DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + providerOptions: DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, abortSignal: AbortSignal.timeout(DEMAND_TITLE_TIMEOUT_MS), }) return isUsableDemandTitle(text) ? text.trim() : null diff --git a/src/utilities/ai/deepseekFlashDefaults.ts b/src/utilities/ai/deepseekFlashDefaults.ts new file mode 100644 index 00000000..648194b2 --- /dev/null +++ b/src/utilities/ai/deepseekFlashDefaults.ts @@ -0,0 +1,14 @@ +/** + * The shared provider defaults of the `deepseek-flash` call sites that return + * short structured output (theme expansion, rerank, cut/piece metadata and + * demand title). Thinking is disabled because the model spends the output + * budget on reasoning tokens before the payload (measured: 93–354 per call) — + * with the old small caps the JSON/text was truncated on a large share of real + * calls and every caller degraded silently to its fallback. The cap is + * generous for the same reason: payloads cost ~30–300 tokens. + */ +export const DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS = { + deepseek: { thinking: { type: 'disabled' } }, +} as const + +export const DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS = 1000 diff --git a/src/utilities/ai/expandSpeechSearchTheme.ts b/src/utilities/ai/expandSpeechSearchTheme.ts index e3555f50..c45c416c 100644 --- a/src/utilities/ai/expandSpeechSearchTheme.ts +++ b/src/utilities/ai/expandSpeechSearchTheme.ts @@ -10,9 +10,12 @@ import { MIN_SPEECH_THEME_TERMS, normalizeSpeechThemeTerms, } from '@/lib/speechThemeTerms' +import { + DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, +} from '@/utilities/ai/deepseekFlashDefaults' const EXPAND_TIMEOUT_MS = 4000 -const MAX_OUTPUT_TOKENS = 200 const MAX_THEME_LENGTH = 200 const EXPAND_SUBJECT_BY_CORPUS: Record = { @@ -119,7 +122,8 @@ export const expandSearchTheme = async ( // Deterministic as the provider allows: pages 2+ of the same search must // not be built from a different expansion. temperature: 0, - maxOutputTokens: MAX_OUTPUT_TOKENS, + maxOutputTokens: DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + providerOptions: DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, abortSignal: AbortSignal.timeout(EXPAND_TIMEOUT_MS), }) diff --git a/src/utilities/ai/rerankSpeechExcerpts.ts b/src/utilities/ai/rerankSpeechExcerpts.ts index e0d31d67..50b13dcd 100644 --- a/src/utilities/ai/rerankSpeechExcerpts.ts +++ b/src/utilities/ai/rerankSpeechExcerpts.ts @@ -4,10 +4,14 @@ import { deepSeek } from '@ai-sdk/deepseek' import { generateObject } from 'ai' import { z } from 'zod' +import { + DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, +} from '@/utilities/ai/deepseekFlashDefaults' + const RERANK_TIMEOUT_MS = 6000 const MAX_REASON_LENGTH = 140 const MAX_CITATION_CHARS = 700 -const MAX_OUTPUT_TOKENS = 400 export type SpeechExcerptRerankCandidate = { /** Pool index the tool maps back to the excerpt. */ @@ -88,7 +92,8 @@ export const rerankSpeechExcerpts = async ({ ), ].join('\n'), temperature: 0.2, - maxOutputTokens: MAX_OUTPUT_TOKENS, + maxOutputTokens: DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + providerOptions: DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, abortSignal: AbortSignal.timeout(RERANK_TIMEOUT_MS), }) diff --git a/src/utilities/content/contentPieceCataloging.ts b/src/utilities/content/contentPieceCataloging.ts index 339dd9fc..d4d53a30 100644 --- a/src/utilities/content/contentPieceCataloging.ts +++ b/src/utilities/content/contentPieceCataloging.ts @@ -17,6 +17,10 @@ import { institutionCatalog, institutionSpellings } from '@/lib/institutionCatal import { type SpeechTopic } from '@/lib/speechFacets' import { matchMunicipalityMentions, type SpeechFacetInput } from '@/lib/speechGazetteer' import { normalizeForSearch } from '@/lib/speechSearch' +import { + DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, +} from '@/utilities/ai/deepseekFlashDefaults' import { classifySpeech } from '@/utilities/speech/speechClassifier' /** @@ -30,7 +34,6 @@ import { classifySpeech } from '@/utilities/speech/speechClassifier' const SUGGESTION_TIMEOUT_MS = 8000 const MAX_EXCERPT_CHARS = 2000 -const MAX_OUTPUT_TOKENS = 500 export type ContentPieceCatalogResult = { title?: string @@ -133,7 +136,8 @@ const suggestContentPieceMetadata: ContentPieceSuggester = async ({ .filter((line): line is string => line !== null) .join('\n'), temperature: 0.3, - maxOutputTokens: MAX_OUTPUT_TOKENS, + maxOutputTokens: DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + providerOptions: DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, abortSignal: AbortSignal.timeout(SUGGESTION_TIMEOUT_MS), }) diff --git a/src/utilities/speech/speechCutMetadata.ts b/src/utilities/speech/speechCutMetadata.ts index 44b7f194..648ff189 100644 --- a/src/utilities/speech/speechCutMetadata.ts +++ b/src/utilities/speech/speechCutMetadata.ts @@ -9,12 +9,15 @@ import { clipSpeechCutText, type SpeechCutSourceKind, } from '@/lib/speechCut' +import { + DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, +} from '@/utilities/ai/deepseekFlashDefaults' const SUGGESTION_TIMEOUT_MS = 6000 const MAX_EXCERPT_CHARS = 1200 const MAX_TITLE_LENGTH = 120 const MAX_DESCRIPTION_LENGTH = 600 -const MAX_OUTPUT_TOKENS = 400 type SpeechCutMetadataSource = 'ai' | 'fallback' @@ -139,7 +142,8 @@ export const suggestSpeechCutMetadata = async ({ `Trecho (transcrição automática): "${excerpt}"`, ]), temperature: 0.3, - maxOutputTokens: MAX_OUTPUT_TOKENS, + maxOutputTokens: DEEPSEEK_FLASH_STRUCTURED_MAX_OUTPUT_TOKENS, + providerOptions: DEEPSEEK_FLASH_STRUCTURED_PROVIDER_OPTIONS, abortSignal: AbortSignal.timeout(SUGGESTION_TIMEOUT_MS), }) diff --git a/tests/unit/campaignDemandTitle.unit.spec.ts b/tests/unit/campaignDemandTitle.unit.spec.ts new file mode 100644 index 00000000..a042cd2d --- /dev/null +++ b/tests/unit/campaignDemandTitle.unit.spec.ts @@ -0,0 +1,59 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { generateTextMock, deepSeekMock } = vi.hoisted(() => ({ + generateTextMock: vi.fn(), + deepSeekMock: vi.fn(() => 'mock-model'), +})) + +vi.mock('ai', () => ({ generateText: generateTextMock })) +vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock })) + +import { deriveDemandTitle } from '@/utilities/ai/campaignDemandTitle' + +beforeEach(() => { + process.env.DEEPSEEK_API_KEY = 'test-deepseek-key' + generateTextMock.mockReset() + deepSeekMock.mockClear() +}) + +afterEach(() => { + delete process.env.DEEPSEEK_API_KEY +}) + +describe('deriveDemandTitle (B195)', () => { + it('devolve o título quando o modelo responde algo usável', async () => { + generateTextMock.mockResolvedValue({ text: 'Carro de som para a plenária' }) + + await expect(deriveDemandTitle('preciso de carro de som', 'transporte')).resolves.toBe( + 'Carro de som para a plenária', + ) + }) + + it('degrada para null sem chave, em erro ou com saída inutilizável', async () => { + delete process.env.DEEPSEEK_API_KEY + await expect(deriveDemandTitle('pedido')).resolves.toBeNull() + expect(generateTextMock).not.toHaveBeenCalled() + + process.env.DEEPSEEK_API_KEY = 'test-deepseek-key' + generateTextMock.mockRejectedValue(new Error('provider down')) + await expect(deriveDemandTitle('pedido')).resolves.toBeNull() + + generateTextMock.mockResolvedValue({ text: ' ' }) + await expect(deriveDemandTitle('pedido')).resolves.toBeNull() + }) + + it('desliga o thinking e mantém teto de saída acima do orçamento de raciocínio', async () => { + generateTextMock.mockResolvedValue({ text: 'Título curto' }) + + await deriveDemandTitle('pedido qualquer', 'material') + + const call = generateTextMock.mock.calls[0]![0] as { + maxOutputTokens?: number + providerOptions?: unknown + } + expect(call.providerOptions).toEqual({ deepseek: { thinking: { type: 'disabled' } } }) + expect(call.maxOutputTokens).toBeGreaterThanOrEqual(1000) + }) +}) diff --git a/tests/unit/contentPieceCataloging.unit.spec.ts b/tests/unit/contentPieceCataloging.unit.spec.ts index fd0ab883..77a2bc8f 100644 --- a/tests/unit/contentPieceCataloging.unit.spec.ts +++ b/tests/unit/contentPieceCataloging.unit.spec.ts @@ -1,7 +1,12 @@ // @vitest-environment node import type { Payload } from 'payload' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { generateObjectMock } = vi.hoisted(() => ({ generateObjectMock: vi.fn() })) + +vi.mock('ai', () => ({ generateObject: generateObjectMock })) +vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: vi.fn(() => 'mock-model') })) import type { SpeechTopic } from '@/lib/speechFacets' import { @@ -39,6 +44,15 @@ const classifierOf = (topics: SpeechTopic[]) => vi.fn(async () => ({ facets: { t const suggesterOf = (title: string, description: string) => vi.fn(async () => ({ title, description, source: 'ai' as const })) +beforeEach(() => { + process.env.DEEPSEEK_API_KEY = 'test-deepseek-key' + generateObjectMock.mockReset() +}) + +afterEach(() => { + delete process.env.DEEPSEEK_API_KEY +}) + describe('resolveContentPieceMunicipalityId', () => { it('resolves a single unambiguous city mention', async () => { const payload = fakePayload({ municipalitySlug: 'feira-de-santana' }) @@ -193,4 +207,27 @@ describe('catalogContentPiece', () => { expect(result.source).toBe('ai') expect(result.topics).toBeUndefined() }) + + it('desliga o thinking e mantém teto de saída acima do orçamento de raciocínio', async () => { + generateObjectMock.mockReset() + generateObjectMock.mockResolvedValue({ + object: { title: 'Título sugerido', description: 'Descrição sugerida.' }, + }) + + const result = await catalogContentPiece({ + payload: fakePayload(), + type: 'video', + title: 'Peça', + transcript: 'Uma fala qualquer sobre saúde.', + classify: classifierOf([]), + }) + + expect(result.source).toBe('ai') + const call = generateObjectMock.mock.calls[0]![0] as { + maxOutputTokens?: number + providerOptions?: unknown + } + expect(call.providerOptions).toEqual({ deepseek: { thinking: { type: 'disabled' } } }) + expect(call.maxOutputTokens).toBeGreaterThanOrEqual(1000) + }) }) diff --git a/tests/unit/deployScript.unit.spec.ts b/tests/unit/deployScript.unit.spec.ts index b1012da1..76a5f197 100644 --- a/tests/unit/deployScript.unit.spec.ts +++ b/tests/unit/deployScript.unit.spec.ts @@ -97,6 +97,34 @@ describe('scripts/deploy-homeserver.sh (OPS53 deploy pipeline)', () => { } }) + it('audits the running container feature envs after the healthcheck, non-fatally', () => { + // Incident 2026-09-24: prod/staging ran without DEEPSEEK_API_KEY and every + // AI-backed feature degraded silently (theme search showed the unavailable + // notice). The audit reads the CONTAINER env — the compose env_file is the + // truth — and annotates what is missing; it must never fail the deploy + // (which feature keys are set is the human's call). + for (const fragment of [ + 'warn_missing_feature_envs', + 'DEEPSEEK_API_KEY', + 'DEEPINFRA_API_KEY', + 'RESEND_API_KEY', + 'CAMPAIGN_EMAIL_FROM', + 'VAPID_PUBLIC_KEY', + 'VAPID_PRIVATE_KEY', + '::warning::', + ]) { + expect(script, fragment).toContain(fragment) + } + + const definitionIndex = script.indexOf('warn_missing_feature_envs() {') + const callIndex = script.indexOf('warn_missing_feature_envs\n') + const healthIndex = script.indexOf('waiting for the healthcheck') + const smokeIndex = script.indexOf('# --- smoke') + expect(definitionIndex).toBeGreaterThan(healthIndex) + expect(callIndex).toBeGreaterThan(definitionIndex) + expect(callIndex).toBeLessThan(smokeIndex) + }) + it('never echoes secret values (no set -x, passwords only via stdin/secrets)', () => { expect(script).not.toMatch(/^set -x\b/m) expect(script).not.toContain('echo "$DATABASE_URL"') diff --git a/tests/unit/expandSpeechSearchTheme.unit.spec.ts b/tests/unit/expandSpeechSearchTheme.unit.spec.ts index f083ec2e..e6a75619 100644 --- a/tests/unit/expandSpeechSearchTheme.unit.spec.ts +++ b/tests/unit/expandSpeechSearchTheme.unit.spec.ts @@ -71,6 +71,19 @@ describe('expandSpeechSearchTheme (C192)', () => { expect(call.prompt).toBe('defesa do SUS') expect(call.abortSignal).toBeInstanceOf(AbortSignal) }) + + it('desliga o thinking e mantém teto de saída acima do orçamento de raciocínio', async () => { + generateObjectMock.mockResolvedValue({ object: { terms: ['SUS'] } }) + + await expandSpeechSearchTheme('defesa do SUS') + + const call = generateObjectMock.mock.calls[0]![0] as { + maxOutputTokens?: number + providerOptions?: unknown + } + expect(call.providerOptions).toEqual({ deepseek: { thinking: { type: 'disabled' } } }) + expect(call.maxOutputTokens).toBeGreaterThanOrEqual(1000) + }) }) describe('expandSearchTheme (S28 — corpus)', () => { diff --git a/tests/unit/rerankSpeechExcerpts.unit.spec.ts b/tests/unit/rerankSpeechExcerpts.unit.spec.ts index 566b83aa..9e46dfe4 100644 --- a/tests/unit/rerankSpeechExcerpts.unit.spec.ts +++ b/tests/unit/rerankSpeechExcerpts.unit.spec.ts @@ -126,4 +126,17 @@ describe('rerankSpeechExcerpts (C158)', () => { expect(call.prompt).not.toContain('y'.repeat(701)) expect(call.abortSignal).toBeInstanceOf(AbortSignal) }) + + it('desliga o thinking e mantém teto de saída acima do orçamento de raciocínio', async () => { + generateObjectMock.mockResolvedValue({ object: { choices: [{ index: 0, reason: 'ok' }] } }) + + await rerank() + + const call = generateObjectMock.mock.calls[0]![0] as { + maxOutputTokens?: number + providerOptions?: unknown + } + expect(call.providerOptions).toEqual({ deepseek: { thinking: { type: 'disabled' } } }) + expect(call.maxOutputTokens).toBeGreaterThanOrEqual(1000) + }) }) diff --git a/tests/unit/speechCutMetadata.unit.spec.ts b/tests/unit/speechCutMetadata.unit.spec.ts new file mode 100644 index 00000000..29c77390 --- /dev/null +++ b/tests/unit/speechCutMetadata.unit.spec.ts @@ -0,0 +1,81 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { generateObjectMock, deepSeekMock } = vi.hoisted(() => ({ + generateObjectMock: vi.fn(), + deepSeekMock: vi.fn(() => 'mock-model'), +})) + +vi.mock('ai', () => ({ generateObject: generateObjectMock })) +vi.mock('@ai-sdk/deepseek', () => ({ deepSeek: deepSeekMock })) + +import { suggestSpeechCutMetadata } from '@/utilities/speech/speechCutMetadata' + +const segments = [ + { startSeconds: 0, endSeconds: 12, text: 'A saúde pública precisa de mais investimento.' }, +] + +const suggest = (overrides: Partial[0]> = {}) => + suggestSpeechCutMetadata({ + speechType: 'Discurso', + dateLabel: '07/02/2023', + summary: 'Saúde e educação em Feira de Santana.', + segments, + startSeconds: 0, + endSeconds: 12, + ...overrides, + }) + +beforeEach(() => { + process.env.DEEPSEEK_API_KEY = 'test-deepseek-key' + generateObjectMock.mockReset() + deepSeekMock.mockClear() +}) + +afterEach(() => { + delete process.env.DEEPSEEK_API_KEY +}) + +describe('suggestSpeechCutMetadata (C167)', () => { + it('devolve a sugestão da IA validada', async () => { + generateObjectMock.mockResolvedValue({ + object: { + title: 'Mais investimento na saúde', + description: 'Solla defende a atenção básica.', + }, + }) + + await expect(suggest()).resolves.toEqual({ + title: 'Mais investimento na saúde', + description: 'Solla defende a atenção básica.', + source: 'ai', + }) + }) + + it('degrada para o fallback sem chave, sem trecho ou em erro do provedor', async () => { + delete process.env.DEEPSEEK_API_KEY + await expect(suggest()).resolves.toMatchObject({ source: 'fallback' }) + expect(generateObjectMock).not.toHaveBeenCalled() + + process.env.DEEPSEEK_API_KEY = 'test-deepseek-key' + await expect(suggest({ segments: [] })).resolves.toMatchObject({ source: 'fallback' }) + expect(generateObjectMock).not.toHaveBeenCalled() + + generateObjectMock.mockRejectedValue(new Error('provider down')) + await expect(suggest()).resolves.toMatchObject({ source: 'fallback' }) + }) + + it('desliga o thinking e mantém teto de saída acima do orçamento de raciocínio', async () => { + generateObjectMock.mockResolvedValue({ object: { title: 'Título', description: 'Descrição' } }) + + await suggest() + + const call = generateObjectMock.mock.calls[0]![0] as { + maxOutputTokens?: number + providerOptions?: unknown + } + expect(call.providerOptions).toEqual({ deepseek: { thinking: { type: 'disabled' } } }) + expect(call.maxOutputTokens).toBeGreaterThanOrEqual(1000) + }) +})