teqo-backup/docs/plans/relatorios-sem-truncamento-impl.md
Francisco Solla 4b00a8778a feat(C188): relatórios e dossiês sem truncamento mecânico
Dono único de orçamento de texto em reportText.mjs (capList,
excerptDeepDive, joinWithRemainder, summaryText/summarySurfaceText,
summaryListText), reusado por C163 e C186. Contrato summary por item nos
dois arquivos de pesquisa; superfície de resumo nunca corta com "…" e
aponta o aprofundamento; toda lista capada mostra contador; builders com
2 passadas guard-gated (integral → ponteiro → die fail-closed).

Closes #1136
2026-09-17 19:59:02 -03:00

159 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Impl: Relatórios e dossiês sem truncamento mecânico — reformular para caber ou ampliar o campo
Status: aprovado
Atualizado em: 2026-09-17
Issue: #1136
Intenção: docs/plans/relatorios-sem-truncamento.md
Appetite restante: herdado (~1–2 dias eng)
Design UI: N/A — superfície inalterada; alvo é o hi-fi já aprovado do C186 e do C163 (Impeccable B)
## Leitura da intenção
- **Outcome:** gerar relatório/dossiê/boletim com texto longo sem **nenhum "…" em superfície de resumo**, sem item sumindo em silêncio (contador em toda lista truncada), com o **integral preservado no aprofundamento** e a **guarda de fit verde** (fail-closed).
- **O que NÃO negociar:** proibido corte mecânico no resumo; contador em toda lista truncada; integral no aprofundamento; guarda de fit fail-closed; **sem redesenho do hi-fi aprovado C163/C186**; o `summary` é redigido pelo researcher — o builder não redige; sem fonte não publica; empenho ≠ pagamento; esfera/abrangência nunca somadas; PII mínima; artefato gitignored; defeso.
- **O que reavaliar:** (a) a "Direção" sugere dono único substituindo `excerpt()` — a hipótese de _onde_ ele vive é decisão de engenharia (D1); (b) a mecânica de "ampliar orçamento enquanto a guarda permitir" precisa de um mecanismo concreto (D5); (c) C157 pode virar sucessor (D-assumida).
**Natureza explícita:** mudança de **`scripts/` + skills**. **Sem Payload schema, sem migration, sem contrato de URL pública, sem Consent/LGPD**; `push:false` intocado; nenhuma collection/global/field tocado.
## Abordagem recomendada
```mermaid
flowchart LR
R["research.json: answer/details integral + summary opcional"] --> B["build CityReport / DossierReport"]
B -->|"item.summary existe"| S["superfície de resumo usa summary"]
B -->|"sem summary"| A["superfície usa answer integral"]
S --> G{"guarda de fit: scrollHeight > orçamento?"}
A --> G
G -->|"cabe"| P["render PDF"]
G -->|"estoura"| F["2ª passada textFallback=pointer:<br/>linha aponta o aprofundamento, SEM ..."]
F --> G2{"guarda de fit"}
G2 -->|"cabe"| P
G2 -->|"estoura"| D["die — fail-closed"]
B --> C["capList: items + total + remaining"]
C --> RC["renderer imprime 'e mais N' / 'Mostrando X de Y'"]
R --> DD["aprofundamento: answer/details integrais"]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — dono único estendido em `reportText.mjs`, `summary` opcional por item, contador `{items,total,remaining}`, fallback de ponteiro sem "…", e ampliação decidida pela guarda em 2 passadas.
**Rejeitadas:** ver D1–D5.
### Decisões de engenharia
**D1 — Onde vive o dono único de orçamento/composição de texto (substitui o `excerpt()` do C163)?**
Opções: A) estender `scripts/lib/reportText.mjs` (contrato compartilhado de texto impresso, já "one owner, two consumers" para C163+C186) | B) módulo novo `scripts/lib/reportBudget.mjs` | C) manter em `cityReportBlocks.mjs` e importar no C186.
Recomendação: **A** — `reportText.mjs` já é o dono declarado do texto impresso consumido pelos dois relatórios; adicionar `capList`/`excerptDeepDive`/`joinWithRemainder`/`summaryText` ali **estende o dono** em vez de gemar, e mantém o módulo puro (não importa modelo de blocos).
Rejeitadas: **B** porque cria um segundo lugar para o mesmo conceito ("texto compartilhado"), exatamente o twin que o AGENTS proíbe; **C** porque faria o C186 (dossiê) depender do modelo de blocos do C163 — inversão de dependência e grafo pesado.
**D2 — Forma e fallback do `summary`.**
Opções: A) `summary` opcional em `items[]` dos dois contratos de pesquisa, sem fonte própria (herda a do item); builder prefere `summary`, senão `answer` | B) `summary` global por arquivo | C) `*Summary` em todo campo livre (`approach`, `leaders`, `leaderAgenda`, `emendasIndicators[].purpose`…).
Recomendação: **A** — resolve na origem e dá ao researcher o controle editorial; é o menor contrato que cobre as superfícies de resumo; herda a fonte do item (não é fato novo, não passa por "sem fonte não publica" de novo).
Rejeitadas: **B** porque o próprio plano de intenção rejeita global ("summary por item, auto-contido por superfície"); **C** porque explode contrato/validador/testes por ganho marginal — `purpose` de indício segue sem corte, coberto pelo fallback de linha.
**D3 — Forma do contador (toda lista truncada).**
Opções: A) `capList(list, max)` devolve `{ items, total, remaining }`; renderizadores imprimem a copy que já existe no repo ("e mais N" em listas; "Mostrando X de Y" em tabelas/painéis) | B) só `remaining` | C) só `total`/`shown`.
Recomendação: **A** — reusa as três formas já impressas (indicatorList, table note, region scope), não perde o total e cobre corte por desenho e por lista.
Rejeitadas: **B** porque não sustenta "Mostrando X de Y" onde o corte é por desenho; **C** porque perde o "e mais N" das listas.
**D4 — O que fazer quando nem `summary` nem o `answer` cabem.**
Opções: A) linha-ponteiro **sem reticências** ("Texto integral no aprofundamento — <seção>") | B) cortar com "…" | C) deixar a guarda abortar.
Recomendação: **A** — literal do plano de intenção ("a linha aponta o aprofundamento sem reticências"); o integral já vive no aprofundamento.
Rejeitadas: **B** porque viola o outcome; **C** porque a guarda fail-closed é a última linha de defesa, não a primeira.
**D5 — Mecânica de "ampliar o orçamento enquanto a guarda permitir".**
Opções: A) 2 passadas guard-gated: (1) `summary ?? answer` integral; se a guarda estourar, (2) rebuild com `textFallback:'pointer'` (só campos sem `summary` viram ponteiro; o `summary` do researcher é preservado); ainda estourou → `die` | B) descida em 3 degraus (integral → caps de referência → ponteiro) | C) manter caps fixos atuais e só trocar "…" por ponteiro.
Recomendação: **A** — a guarda é o teto real ("amplia enquanto permitir"); a 2ª passada cobre o caso comum (researcher sem `summary`) sem corte mecânico; determinístico, testável (o modelo aceita a flag, o builder orquestra as 2 medições). O `die` final permanece e, quando dispara com `summary` presente, aponta o arquivo de pesquisa a encurtar.
Rejeitadas: **B** porque custa mais uma medição/estado para ganho marginal; **C** porque mantém o corte mecânico e não honra "prefere `answer`".
### Componentes / mudanças
- **`scripts/lib/reportText.mjs`** (dono único; atualiza o header para declarar orçamento/composição): ganha `capList(list, max) → {items,total,remaining}`, `excerptDeepDive(text, max)` (corte explícito com "…", **só aprofundamento**), `joinWithRemainder(values, limit)` (junção por contagem, sempre "e mais N", **sem** corte de caractere), `summaryText(item) → {text, fromSummary}` e `SUMMARY_POINTER_COPY`. Puro, sem modelo de blocos. Substitui o `excerpt()` do C163.
- **`scripts/lib/cityReportResearch.mjs`** e **`scripts/lib/dossieResearch.mjs`**: `items[]` passa a aceitar `summary` (`isNonEmptyString → trim`, senão `null`; **não** gera lacuna — é fallback de renderização). `news[].summary` fica como está (não tocado).
- **`scripts/lib/cityReportBlocks.mjs`**: remove `excerpt()`; todos os call-sites de página 1 (`:158,198,232,236,248,255,288,310/311,322,350,368,382`) usam `summaryText(item)`; `joinLimited` → `joinWithRemainder`; slices silenciosos do aprofundamento (`:870` sinal, `:1097` menção, `:1113` sumário de fala) → `excerptDeepDive`; `buildCityReport({ textFallback = 'full' })` — em `'pointer'`, campos sem `summary` imprimem o ponteiro. Integral permanece em `buildResearchAnswersTable`.
- **`scripts/build-city-report.mjs`**: loop de 2 passadas (build → render → medir `[data-page="summary"]`; se `> PAGE_ONE_BUDGET_PX`, rebuild com `textFallback:'pointer'`; se ainda estourar → `die`). Tolerância/budget atuais intocados; mensagem de `die` atualizada.
- **`scripts/lib/dossieBlocks.mjs`**: `limit()` → `capList`; `deliveries/hooks/pending/era.numbers/era.actions/regionItems` carregam `{items,total,remaining}` (fim do descarte silencioso); `buildDossierReport({ textFallback = 'full' })` para os títulos/sumários de página 1.
- **`scripts/lib/dossieRender.mjs`**: `renderDeliveries`/`renderHooksAndPending`/`renderEra`/região imprimem o contador reusando `.meta`/`.muted small` ("Mostrando X de Y — o restante está nas eras"). Região já tem `total` (:308/:317).
- **`scripts/lib/dossieBulletin.mjs`** + **`dossieBulletinRender.mjs`**: boletim passa a expor `remaining`/`total`; renderer imprime "e mais N item(ns) no dossiê" na seção "E mais".
- **`scripts/lib/dossieCamara.mjs`**: `(sumario ?? transcricao).slice(0,280)` → `excerptDeepDive` (aprofundamento, "…" explícito); nota de limite de coleta para o teto de 20 da API (sem inventar total).
- **`scripts/build-dossie-solla-cidade.mjs`**: mesmo loop de 2 passadas para dossiê e boletim; mensagens de `die` atualizadas.
- **Skills**: `.agents/skills/relatorio-cidade/SKILL.md` (§Contrato dos JSONs :131-158 + copy de página 1 :305-322) e `.agents/skills/dossie-solla-cidade/SKILL.md` (§Contrato dos JSONs :141-170 + item/notes :117-139) documentam `summary` por item ("curto, completo e auto-contido, redigido para caber no orçamento da superfície de resumo") e a regra "integral no aprofundamento; sem `summary`, o builder prefere `answer`; nunca há corte no resumo".
- **Migration:** **sem migration** (nenhum schema Payload tocado).
- **Access / Consent:** N/A — sem write path, sem PII, sem auth; nada de `Consent`/`overrideAccess`.
- **UI:** N/A — Impeccable B; encaixe no hi-fi aprovado; sem componente/estado/layout novo. Se algo exigir mudança material de layout, o `designer` entra e volta ao humano no PR.
### Dados → forma (se aplicável)
- **Forma:** contador **textual** reusando padrões existentes — "e mais N" (listas/bullets) e "Mostrando X de Y" (tabelas/painéis/notas) — e ponteiro textual para o aprofundamento no limite. É a menor mudança sobre o hi-fi aprovado e não introduz linguagem visual nova.
- **Rejeitadas:** ícone/`badge` de "há mais" (redesenho e novo vocabulário visual); reticências como marcador de resto (é o defeito a eliminar); paginação/expansão (não cabe em PDF A4 de uma página, é redesign).
## Fases verificáveis
1. **Dono único (tracer)** — `reportText.mjs`: `capList`, `excerptDeepDive`, `joinWithRemainder`, `summaryText` + `SUMMARY_POINTER_COPY`. Novo `tests/unit/reportText.unit.spec.ts`: `capList` devolve `items/total/remaining`; `excerptDeepDive` marca "…" uma única vez e respeita `max`; `summaryText` prefere `summary` e cai para `answer`; **nenhum primitivo de superfície de resumo emite "…"**. Quota ~2–3h.
2. **Contrato `summary`** — `cityReportResearch.mjs` + `dossieResearch.mjs`; casos novos nos specs respectivos (summary trimado, vazio→null, sem lacuna, item segue publicado só com a fonte do `answer`). Skills atualizadas (contrato + copy de página 1). Quota ~2–3h.
3. **C163** — remove `excerpt`, call-sites de página 1 via `summaryText`, `joinWithRemainder`, slices do aprofundamento via `excerptDeepDive`; flag `textFallback`; 2 passadas no `build-city-report.mjs`. Atualiza `tests/unit/cityReportBlocks.unit.spec.ts:702,752,780` (o :752 deixa de esperar `endsWith('…')`) e `tests/unit/cityReportRender.unit.spec.ts:127,166`; novo teste "página 1 sem '…' e integral presente". Quota ~4–5h.
4. **C186 + boletim** — `capList` e contadores em deliveries/hooks/pending/era/region; boletim `remaining`/`total`; `dossieCamara` com corte explícito de aprofundamento; 2 passadas no `build-dossie-solla-cidade.mjs`. Atualiza `tests/unit/dossieBlocks.unit.spec.ts:155` e `tests/unit/dossieBulletin.unit.spec.ts:27,60`; novos testes de contador (nenhum item some; `highlights+moreItems+remaining === total`). Quota ~4–5h.
5. **Gates** — `pnpm lint`, `pnpm typecheck`, `pnpm test:unit` (ou `pnpm gate:fast`); `pnpm knip`; checagem de ciclos do CI; push via `pnpm push`. Quota ~1–2h.
## Rabbit holes / Não escopo (engenharia)
- **Varredura de todo `slice` do repo** — só a família C163/C186.
- **C157** (`build-solla-ceuci-salvador-report.mjs`, self-contained, "…" próprios em :392/:446 e slices :133/:142/:601) — **sucessor**, não este item; gatilho de revisitação: se o dono novo provar drop-in de ~1h no C157, incluir; senão abrir Issue sucessora.
- **Builder redigir/encurtar texto** — proibido; sem `summary`, o texto é integral ou ponteiro; nunca reescrito.
- **`summary` global** — rejeitado (D2).
- **Ampliação como licença para crescer** — a guarda fail-closed é o teto; nada de espremer layout.
- **Confundir "e mais N" (lista) com "…" (frase)** — remediações distintas, ambas tratadas.
- **`news[].summary` validado e nunca renderizado** — não mexer aqui (não é superfície do problema; bullet-in separado se houver demanda).
- **Redesenho do hi-fi C163/C186** — fora; se material, aciona `designer`.
- **Payload schema/migration/URL pública/Consent/LGPD** — nada.
## Riscos e mitigação
- **Página 1 estoura com a pesquisa real** → 2 passadas guard-gated (D5); guarda fail-closed preservada; teste com `answer` longo sem `summary` e com `summary`.
- **Pesquisas existentes sem `summary`** → fallback `answer` integral; `summary` é opt-in, sem regressão e sem lacuna nova.
- **Linha de contador empurra o layout aprovado do C186** → nota curta reusando `.meta`/`.muted small`; se estourar, a guarda aborta e a copy do contador é encurtada antes de qualquer mudança visual; se exigir mudança material → `designer`.
- **Corte explícito com "…" no aprofundamento confundido com resumo** → `excerptDeepDive` só é usado em seções de aprofundamento (sinais, acervo de falas, ações da Câmara); teste de renderer garante ausência no `[data-page="summary"]`.
- **Duplicação C163↔C186** → dono único em `reportText.mjs`; `knip`/ciclos no CI.
- **Testes que hoje afirmam "…"** → atualização deliberada e listada (Fase 3/4), com o novo aceite "sem '…' no resumo".
- **Câmara sem total confiável da API** → contar o que o artefato imprime (era/deliveries) e rotular o limite de coleta; nunca inventar total.
## Decisões assumidas
Registradas do §Questões em aberto da intenção (fluxo `--auto` grava os literais assumidos):
1. **Campo `summary` vs só ampliar orçamento** → **A) `summary` por item + ampliação como fallback** (assumido).
2. **"…" sempre proibido ou só no aprofundamento** → **A) proibir nas superfícies de resumo, manter no aprofundamento** (assumido).
3. **Cobrir C157 agora ou sucessor** → **B) sucessor** (assumido; gatilho de revisitação em Não escopo).
4. **Prioridade P1 vs P2** → **B) P2** (assumido; reavaliar para P1 se o C187 entrar em produção imediatamente).
5. Hipóteses de engenharia assumidas: dono em `reportText.mjs` (D1), contador `{items,total,remaining}` (D3), 2 passadas guard-gated (D5), `summary` herda a fonte do `answer` (D2).
6. Sem Payload schema/migration/URL pública/Consent/LGPD; `push:false` intocado.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: nenhum "…" em superfície de resumo; toda lista truncada com contador; integral no aprofundamento; guarda verde fail-closed; contrato `summary` documentado nas duas skills.
- [ ] Invariantes AGENTS/engineering-standards: "edite o dono, não gema um irmão" (dono único em `reportText.mjs`, reusado por C163+C186); sem Payload/migration/URL/Consent; scripts puros.
- [ ] Testes de domínio previstos (unit) onde os caps/contrato mudam: `cityReportBlocks`, `cityReportRender`, `dossieBlocks`, `dossieBulletin`, `cityReportResearch`/`dossieResearch` e o novo `reportText`.
- [ ] `pnpm gate:fast` (lint + typecheck + unit) e `pnpm knip` verdes; push via `pnpm push`.
- [ ] Nenhuma migration criada; `push:false` inalterado.
## Desvios na execução
- **Boletim sem 2ª passada de ponteiro.** O plano previa o "mesmo loop de 2
passadas para dossiê e boletim". Na execução, o defeito do boletim era o
**descarte silencioso** (teto 20 sem contador), não corte de texto; o texto por
fato já é capado por desenho e a guarda é fail-closed. Então: contador
`factsRemaining` ("e mais N fatos com fonte no dossiê") + `summary ?? answer`
como headline, sem 2ª passada. O dossiê e o C163 mantêm a 2ª passada.
- **Indício de emenda (`emendasIndicators[].purpose`) nunca vira ponteiro.** Não
há cópia do `purpose` no aprofundamento; então ele entra integral (sem "…") e a
guarda fail-closed decide — o researcher encurta se estourar. Coerente com a
decisão D2 ("purpose segue sem corte").
- **Nota do limite de coleta da Câmara no método da Era C**, não num campo novo
de dados (não inventa total): o texto do `eraMethod.C` declara que a coleta é
limitada às páginas consultadas.
- **`region.items`/`era.numbers`/`era.actions` ganham contador, sem paginação.**
O integral dessas listas não vive em outra página; o contador torna o descarte
declarado (fim do silêncio), sem redesenho — conforme o appetite.
## Self-score decision-quality
**Score: 5/5** — (1) decisões caras (dono único, contrato `summary`, contador, fallback, mecânica de ampliação) têm rejeitadas explícitas em D1–D5; (2) cabe no appetite herdado (5 fases, ~14–18h); (3) rabbit holes nomeados e cortados (C157 sucessor, varredura de slices, builder-redator, `news[].summary`); (4) depth check: reusa o dono existente `reportText.mjs` e as três formas de contador já impressas, sem módulo/helper especulativo; (5) o outcome de produto permanece intacto — a engenharia não o reescreveu (nada de "…", integral preservado, guarda fail-closed).