mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
fix(C192/S28/C219): busca por tema indisponivel — thinking do deepseek-flash truncava o JSON
This commit is contained in:
parent
c9ab467f23
commit
00e393882f
17 changed files with 429 additions and 12 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-24-busca-por-tema-thinking.md
Normal file
1
docs/changelog/2026-09-24-busca-por-tema-thinking.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
80
docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md
Normal file
80
docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
src/utilities/ai/deepseekFlashDefaults.ts
Normal file
14
src/utilities/ai/deepseekFlashDefaults.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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<ThemeSearchCorpus, string> = {
|
||||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
|||
59
tests/unit/campaignDemandTitle.unit.spec.ts
Normal file
59
tests/unit/campaignDemandTitle.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
81
tests/unit/speechCutMetadata.unit.spec.ts
Normal file
81
tests/unit/speechCutMetadata.unit.spec.ts
Normal file
|
|
@ -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<Parameters<typeof suggestSpeechCutMetadata>[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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue