Status: proposta, não decidida. Planejado em 13/10/2026 com auxílio de Claude Code (agente de planejamento), a pedido da Luiza, para discussão da squad depois da E2. Nada aqui foi implementado. A decisão vira o ADR 0008 quando a squad aprovar.
Base lida: README.md, Makefile, docker-compose.yml, .github/workflows/test.yml, ADRs 0001/0003/0004/0006/0007, AI-USAGE.md (entradas de 10 a 13/10), todo src/ingestor/ (4.730 linhas Go: 2.190 de código, 2.540 de teste; 112 funções Test* + 19 subtestes), src/conselhos/identidade.py, src/lake/sync.py e cdc.py, scripts/carga.sh, tests/**. HEAD local está desanexado em 5b08554 (CDC); o plano é contra origin/main.
Números de referência que o plano usa como gabarito (de AI-USAGE.md, 12/10, make ingest-mensal MES=2026-08 em banco limpo): S01082026.zip tem 52,6 MB, 8.590 matérias + 120 imagens, 0 falhas, nenhum id repetido; o persist gravou 96 atos, enfileirou 8.494 matérias de órgão desconhecido, orgao_alias ficou com 751 linhas; segunda execução gravadas=0 inalteradas=96.
1. Inventário: o que cada pacote/comando Go faz e o equivalente Python
| Go (linhas código / teste) | O que faz | Equivalente Python sugerido | Dependências |
|---|---|---|---|
internal/store/store.go (195 / 279, 18 testes) | Store.Save: temp no mesmo dir (name.part-*), copia, fsync, valida com zip.NewReader, rename, apaga marcadores .sem-edicao/.falhou, fsync do diretório. Has, MarkNoEdition, MarkFailed (ZIP em disco vence; marcador novo apaga o outro antes de escrever). New recusa dir inexistente. | src/dou/bronze.py: classe Bronze(dir) com salvar(nome, fileobj), tem(nome), marcar_sem_edicao, marcar_falhou. | stdlib: tempfile.NamedTemporaryFile(dir=, prefix=nome+".part-", delete=False), shutil.copyfileobj, os.fsync, zipfile.ZipFile (abre o diretório central, como zip.NewReader; não usar is_zipfile, que só olha o fim do arquivo), os.replace, os.open(dir)+os.fsync |
internal/dadosabertos/dadosabertos.go (226 / 198, 8 testes) | ParseMonth (só YYYY-MM), ZipName (S01MMAAAA.zip), FetchMonth: Has → sem requisição; lê a listagem /acesso-a-informacao/dados-abertos/base-de-dados?ano=&mes=Agosto (nomes em monthNames, com "Março"), linkTo acha href cujo path contém /S01082026.zip/, html.UnescapeString, resolve contra a base, recusa host diferente; GET com User-Agent: conselhos-vivos-ingest/0.1 e Accept: */*; ErrNotPublished; timeout de 10 min no mês; limite de 5 MB na página. | src/dou/dadosabertos.py: Mes, nome_do_zip, baixar_mes(mes, bronze, base_url=...), link_para(pagina, nome, base). | stdlib: urllib.request (honra proxies do ambiente como ProxyFromEnvironment), urllib.parse.urljoin/urlsplit, html.unescape, re (href="([^"]+)"), datetime.strptime("%Y-%m"). Timeout: urlopen(req, timeout=30) cobre conexão/leitura por fase; o teto de 10 min vira contagem de relógio no laço de cópia, ou se aceita só o timeout por fase (registrar no PR). |
cmd/fetch-mensal/main.go (70) + internal/config.FetchMensal | CLI -month; DADOS_ABERTOS_OUTPUT_DIR (padrão .); saída 2 (uso), 1 (erro/mês não publicado), log iniciando/`mes ... desfecho=baixada | ja-baixada`. | python -m dou fetch-mensal --month AAAA-MM em src/dou/__main__.py (dispatch como `python -m conselhos {sql |
internal/article/article.go + text.go (252 / 220, 10 testes) | Read(zip): itera entradas, .xml em qualquer profundidade (case-insensitive), pasta/imagem → ErrNotXML; parse: exatamente um <article>, exige id, pubDate DD/MM/YYYY, artCategory não vazio; TrimSpace em todos os campos exceto Texto (HTML cru); splitCategory descarta níveis vazios; Organ() = último nível; PlainText: tokenizer HTML, quebra de linha em p br div tr li table h1-h6, ` | entretd/th, espaços colapsados, entidades decodificadas. Campos JSON: entry,id,idMateria,pubName,artType,pubDate,numberPage,editionNumber,artClass,artCategory,identifica,ementa,titulo,subTitulo,html(omitempty),texto`. | src/dou/materia.py: @dataclass Materia, ler(zipfile.ZipFile) gerador de `(Materia |
cmd/extract/main.go (106 / 106, 3 testes) | [-html] EDITION.zip → uma linha JSON por matéria no stdout; resumo lidas/ignoradas/falhou no stderr; saída 1 se falhou>0 ou ZIP não abre; 2 se nº de args ≠ 1. | python -m dou extract [--html] ZIP. | stdlib. |
internal/oltp/key.go (45 / 58, 2 testes) | squeeze + Key = espacos() + chave() de src/conselhos/identidade.py (regexes idênticos, NFKD, remove unicodedata.combining, casefold). Paridade por testdata/chaves.tsv (169 pares). | Não portar: importar conselhos.identidade.chave e espacos. A duplicação some; chaves.tsv vira arquivo-ouro só do Python (mover para tests/conselhos/chaves.tsv). | — |
internal/oltp/oltp.go (197 / 418, 14 testes) | Open: lê orgao_nome ∪ orgao_alias(RESOLVIDO) → chave→orgao_id, chave com 2 ids vira ambiguous e sai do mapa; lê orgao_alias WHERE status <> 'RESOLVIDO' → waiting. Save: ErrNoContent se <Texto> em branco; órgão desconhecido → queue (uma linha INDEFINIDO/AMBIGUO, fonte='DOU', motivo com artCategory, ON CONFLICT (fonte, nome) DO NOTHING, não reenfileira chave já waiting); senão um único statement WITH versions AS (...) INSERT ... WHERE NOT EXISTS (same) RETURNING versao comparando todas as versões por data_publicacao, ementa IS NOT DISTINCT FROM NULLIF($4,''), conteudo; sem linha → Unchanged; versao>1 → NewVersion. | src/dou/oltp.py: class Gravador com abrir(conn), salvar(materia) → Desfecho (Enum: GRAVADA, VERSAO_NOVA, INALTERADA, ORGAO_DESCONHECIDO), SemConteudo(Exception). O SQL é copiado como está, trocando $n por %s. | psycopg[binary]==3.2.5 (já em src/lake/requirements.txt). date Python → DATE; a.PubDate.In(time.UTC) é só a conversão civil→timestamp do Go, não há equivalente a fazer. |
cmd/persist/main.go (185 / 221, 6 testes) | EDITION.zip → conexão pelas PG*, uma transação por ZIP, seen[id] → repetida (vale a primeira), resumo gravadas/versoes-novas/inalteradas/orgao-desconhecido/repetidas/ignoradas/falhou; commit mesmo com falhas de entrada e saída 1. | python -m dou persist ZIP; função persistir(conn_ou_tx, logger, caminho) -> Resumo separada do main para o teste. | psycopg com autocommit=False (não reusar lake.sync.obter_conexao, que usa autocommit=True; criar dou/db.py::conectar() lendo PG*/POSTGRES_* do mesmo jeito). |
internal/dbtest/dbtest.go (39) | Transação nunca confirmada; t.Skip sem PGHOST. | tests/dou_db/dbtest.py: class CasoComBanco(unittest.TestCase) com setUp (psycopg.connect), tearDown (rollback+close), @unittest.skipUnless(os.getenv("PGHOST"), ...). | psycopg. |
internal/config/config.go (60 / 117, 7 testes) | .env opcional via -env (godotenv), ambiente vence; INLABS_EMAIL/SENHA obrigatórios; *_OUTPUT_DIR. | Some com o INLABS. Para o mensal basta os.environ.get("DADOS_ABERTOS_OUTPUT_DIR", "."). | — |
internal/inlabs/ (540 / 995, 37 testes) + cmd/fetch (122 / 81, 6 testes) | Login POST /logar.php com header origem, cookie inlabs_session_cookie como única prova, sem seguir 302, retryablehttp com backoff+Retry-After, 404 → .sem-edicao, 302/200-html → sessão expirada (renova uma vez), FetchDay DO1+DO1E com timeout por seção, Today em America/Sao_Paulo (tzdata embutido), piso 2020-01-01, nenhuma credencial em log. | Não migrar agora; descartar com ADR (ver fatia 4). O login é recusado com 502 para qualquer script (#42). O contrato do bronze (data/bronze/inlabs/AAAA-MM-DD-DO1[E].zip + marcadores) é preservado por bronze.py e pelo TEM_EDICAO do Makefile. | Se um dia voltar: urllib + http.cookiejar, zoneinfo (python:3.12-slim não traz /usr/share/zoneinfo: precisaria de pip install tzdata). |
go.mod: civil, caarlos0/env, retryablehttp, pgx/v5, godotenv, x/net/html, x/text, testify | — | Tudo coberto pela stdlib + psycopg. Testes com unittest (padrão do repo, tests/planilha e tests/conselhos), sem pytest. | — |
2. Ordem de migração (fatias verticais, cada uma mergeável com make test verde)
Princípio: durante a transição Go e Python coexistem na árvore; cada fatia troca um serviço compose e um alvo do Makefile de Go para Python, mantém o alvo Go como *-go até a última fatia, e traz um script temporário de equivalência. Nada é apagado antes de provado.
F0 infra Python (pacote src/dou, serviço compose, alvo de teste, CI) sem mudança de comportamento
F1 fetch-mensal = bronze.py + dadosabertos.py + CLI troca o serviço fetch-mensal
F2 extract = materia.py (+ extrair_texto) + CLI troca o serviço extract
F3 persist = oltp.py (reusa conselhos.identidade) + db.py + CLI troca persist, persist-mensal, test-db-go
F4 INLABS: ADR de descarte; remoção do Go (cmd/fetch, inlabs, config, go.mod, serviços, vet-go, CI)
F5 ADR de linguagem única + limpeza final de README/docsPor que esta ordem: F1→F2→F3 é o caminho que roda de verdade (make ingest-mensal), cada uma com uma prova de equivalência objetiva sobre S01082026.zip, que já está em data/bronze/dados-abertos/. F3 depende de F2 (parser) e F1 (nada, mas convém ter o ZIP baixado pelo Python). A decisão sobre o INLABS (F4) fica por último porque é a única que apaga comportamento, e precisa de ADR.
3. Detalhe por fatia
F0 — Infra do pacote Python (sem comportamento novo)
Criar
src/dou/__init__.py(docstring: ingestão do DOU, ADRs 0001/0004),src/dou/__main__.py(dispatch de subcomandos, por ora vazio/--help),src/dou/requirements.txt(psycopg[binary]==3.2.5, mesma versão do lake).tests/dou/__init__.py(testes sem banco e sem rede externa, rodam no serviçocarga, que já monta./srce./testse temnetwork_mode: none; loopback existe mesmo comnone, o que basta parahttp.serverem thread — confirmar na F1).tests/dou_db/__init__.py(testes com banco; rodam num serviço novo).docker-compose.yml: serviçosdou(modelo dolake:python:3.12.14-slim,pip install -r /app/src/dou/requirements.txtcom volumepipcache,PYTHONPATH=/app/src,./src:/app/src:ro,./data/bronze:/data/bronze,user: ${DOCKER_UID}:${DOCKER_GID}para quem escreve no bronze,PG*edepends_on: dbpara o persist) edou-db-test(mesmo, mais./tests:/app/tests:ro,entrypointempython -m unittest discover -s tests/dou_db).Makefile:test-py-dou(carga -m unittest discover -s tests/dou) etest-db-py(serviçodou-db-test); ambos entram emtest..github/workflows/test.yml: nada ainda (make testjá chama).
Risco: o pip install a cada run (como no lake) custa ~segundos com pipcache; aceito, é o padrão já adotado. Esforço: 3 h.
F1 — fetch-mensal em Python
Criar: src/dou/bronze.py, src/dou/dadosabertos.py, subcomando fetch-mensal em src/dou/__main__.py, tests/dou/test_bronze.py, tests/dou/test_dadosabertos.py. Trocar: serviço compose fetch-mensal → imagem Python (entrypoint python -m dou fetch-mensal "$@", DADOS_ABERTOS_OUTPUT_DIR=/data/bronze/dados-abertos); alvo make fetch-mensal passa --month $(MES). Manter o serviço Go como fetch-mensal-go só até o fim da F1 (apagar no mesmo PR se a prova passar).
Portar os testes (18 store + 8 dadosabertos → ~26 casos unittest)
store_test.go:TestSaveLandsAValidZip,RefusesAnHTMLBody,LeavesNothingWhenTheStreamBreaks(reader que levanta no meio),ReplacesAnExistingFile,IsSafeWhenTwoRunsRaceOnTheSameName(duasthreading.Thread),NewRefusesADirectoryThatIsNotThere,NewRefusesAPathThatIsAFile, os 9 de marcadores (MarkNoEditionidempotente, ZIP vence,Saveapaga marcador, último marcador fica,Hassó vê o ZIP). Diretório viatempfile.TemporaryDirectory; ZIP de teste viazipfile.ZipFile(io.BytesIO(), "w").dadosabertos_test.go:monthServervirahttp.server.ThreadingHTTPServerem127.0.0.1:0numa thread, handler que devolve 403 semAccept: */*, serve alistingfixa (copiar a página do teste Go, com&) e conta requisições. Casos: nome publicado, sóYYYY-MM, salva a Seção 1 (asserta o path do ZIP), mês em disco → 0 requisições, mês não publicado → 1 requisição e dir vazio, download 500 → dir vazio, página no lugar do ZIP → "não é zip", link para outro host → erro cita o host e 1 requisição.
Riscos específicos
urllibsegue redirects por padrão; o Go também (dadosabertosnão desligaCheckRedirect). Mesmo comportamento; registrar.urllibmandaAccept-Encoding: identity; não setargzipà mão (ZIP já é comprimido; o aviso docontext.mdvale).- Permissão do arquivo: Go cria o temp com
0600e orenamemantém (o ZIP em disco hoje tem-rw-------);NamedTemporaryFiletambém é0600. Igual. - Timeout total de 10 min:
urlopen(timeout=30)é por operação de socket; aceitar como "fase", como o Go faz para dial/TLS/headers, e documentar que não há teto global.
Prova de equivalência: mv data/bronze/dados-abertos/S01082026.zip /tmp/go.zip (fora do repo) → make fetch-mensal MES=2026-08 (Python) → cmp /tmp/go.zip data/bronze/dados-abertos/S01082026.zip (o arquivo de um mês fechado não muda; 12/10 já provou que o download bate byte a byte com a amostra). Segunda execução: nenhuma requisição (log ja-baixada). MES=2026-09 → saída 1 e nada em disco. MES=2026-9 → saída 2. Esforço: 8 h.
F2 — extract em Python
Criar: src/dou/materia.py (Materia, ler, extrair_texto, NaoEXML, Malformada, ErroDeEntrada(entrada, causa)), subcomando extract, tests/dou/test_materia.py, tests/dou/test_extract.py. Mover src/ingestor/internal/article/testdata/*.xml + README.md para tests/dou/amostras/ (na F2 copiar; apagar os originais na F4). Os testes Go leem testdata/ relativo ao pacote; cmd/persist e oltp também apontam para lá — por isso copiar, não mover, até a F4. Trocar: serviço extract → Python (./data/bronze:/data/bronze:ro); make extract passa --html em vez de -html (ou aceitar os dois no argparse: add_argument("-html", "--html") funciona).
Portar os testes (10 article + 3 extract): ParseRealArticle (campo a campo, incluindo artClass, BOM/CRLF, Text começa/termina como no Go), ParseConselho (o texto esperado com Art. | Prazo e 1º | 30 dias é o gabarito do extrair_texto), ReadSurvivesBadEntries, FindsXMLAtAnyDepth, ReportsADirectory (entrada pasta/ → NaoEXML), ReadStopsWhenTheCallerDoes (gerador: next() e descartar; conferir que não abriu a 2ª entrada), ParseRefusesIncompleteArticles (sem id, sem pubDate, sem artCategory, dois <article>), SplitCategoryDropsEmptyLevels, OrganOfNoCategory, EntryErrorUnwraps (__cause__). extract: uma linha por matéria (json.loads de cada linha), html só com --html, ZIP que não abre → erro cita o caminho.
Riscos específicos
extrair_texto:HTMLParseré puro Python; em ~50 MB de HTML por mês estimo dezenas de segundos — medir e registrar (o projeto registra medições no AI-USAGE). O Go tokenizer decodifica entidades no texto (tokens.Text());convert_charrefs=Truefaz o mesmo.strings.Fieldsestr.split()ambos tratam espaço Unicode (inclui NBSP) — igual.encoding/xmlcombody>Textopega o primeiro<Texto>; usarfind("body/Texto")(nãoiter). Elemento com filhos (não CDATA): os dois pegam só o texto inicial; a prova sobre 8.590 matérias cobre.- JSON: Go escapa U+2028/2029 mesmo com
SetEscapeHTML(false); Python não. Comparar comjq -c -S .dos dois lados, não pordiffcru.pubDateem Go sai"2026-10-02"(civil.Date.MarshalText): emitirisoformat(). strings.TrimSpace×str.strip(): ambos Unicode. OK.
Prova de equivalência: docker compose run --rm -T extract /data/bronze/dados-abertos/S01082026.zip | jq -c -S . > /tmp/go.jsonl e o mesmo com o serviço Python (--html ligado para comparar o HTML cru também) → diff vazio, 8.590 linhas, resumo lidas=8590 ignoradas=120 falhou=0, mesmo código de saída. Guardar tempo de execução dos dois. Esforço: 8 h.
F3 — persist em Python (a fatia que importa)
Criar: src/dou/db.py (conectar() com PG*/POSTGRES_*, autocommit=False), src/dou/oltp.py, subcomando persist, tests/dou_db/dbtest.py, tests/dou_db/test_oltp.py, tests/dou_db/test_persist.py. Mover src/ingestor/internal/oltp/testdata/chaves.tsv → tests/conselhos/chaves.tsv e ajustar CHAVES em tests/conselhos/test_conselhos.py:26 (o teste test_a_chave_e_a_mesma_do_go passa a chamar-se test_a_chave_nao_mudou: vira arquivo-ouro de regressão, porque orgao_alias.chave já está gravada com esses valores). Na F3 ainda não apagar key.go (o TestKeyAgreesWithThePythonKey lê testdata/chaves.tsv: deixar uma cópia até a F4, ou apagar o teste Go nesta fatia — prefiro apagar internal/oltp inteiro na F3, já que o serviço persist deixa de usá-lo; ver abaixo). Trocar: serviço persist → Python (PG*, depends_on: db, bronze :ro); make persist, make persist-mensal (sem mudança de sintaxe: o argumento é o caminho do ZIP); make test-db-go → test-db-py (serviço dou-db-test); test: chama o novo. Apagar na mesma fatia src/ingestor/internal/oltp/, cmd/persist/, internal/dbtest/, internal/article/, cmd/extract/ e o serviço ingestor-db-test (nenhum outro pacote Go depende deles: inlabs e dadosabertos só usam store). Com isso make test-db-go some e make test-go passa a cobrir só inlabs, store, dadosabertos, config, cmd/fetch.
Portar os testes (14 oltp + 6 persist + 2 key)
- Helpers:
materia_construida(id, *categoria)(equivale ahandmade),amostra_construida()(lêconselho-construido.xmlvialer),novo_orgao(conn, nome)(INSERT INTO orgao DEFAULT VALUES RETURNING id+orgao_nome),contar(conn, sql, *args),estado(conn)(a query dostate()demain_test.go:72,row_to_json). oltp: grava matéria do CONAMA (checaorigem,versao=1, data, ementa,conteudo == htmlcom<p class="assina">), ementa vazia e em branco → NULL, acha o órgão pororgao_nomeantigo e pororgao_alias RESOLVIDO, enfileira desconhecido (INDEFINIDO,fonte='DOU', motivo comartCategory), não enfileira nome já pendente (de qualquer fonte, por chave), não escolhe entre dois órgãos (AMBIGUO), gravar de novo não escreve, não alterna entre duas versões conhecidas, mudança só de órgão não gera versão, republicada vira versão 2 com a 1 intacta, sem<Texto>→SemConteudo, chave bate com aliases carregados (SELECT nome, chave FROM orgao_aliasechave(nome) == chavepara toda linha — este teste é também a verificação do risco Unicode da seção 6).persist:SavesTheEdition(resumogravadas=1 orgao-desconhecido=1 ignoradas=1 falhou=1, +1 ou +2 linhas conforme a Coordenação-Geral da PF já esteja na fila),TwiceLeavesTheDatabaseIdentical(listaestado()igual),RepublishedEditionAsNewVersions(1:false,2:true),OnlyTheFirstOfARepeatedID,TakesAMateriaThatArrivedLate(data_publicacao = 2026-10-02),StopsOnAZipThatDoesNotOpen(antes de tocar o banco). ZIP de teste montado emtempfilecom entradas em ordem alfabética (writeEditionordena por nome: o Python deve fazer o mesmo, senão o caso doidrepetido inverte qual é "a primeira").key_test.go→ já existe emtests/conselhos/test_conselhos.py(Chave.*): nada a portar.
Riscos específicos
NULLIF(%s, '')comstrem psycopg3: o parâmetro vai sem OID (unknown) e o servidor inferetext; se o planner reclamar de tipo, usarNULLIF(%s::text, ''). A comparaçãodata_publicacao = %scomdatetime.dateéDATEnativo.- Transação:
conn.transaction()ouBEGINexplícito; commit no fim do ZIP, saída 1 mesmo com commit quandofalhou>0(comportamento documentado no README). - A race de
uq_ato_dou_versao(duas execuções no mesmo ZIP): o Go deixa o erro subir e aborta; manter igual. - Logs: não há credencial no fluxo (as
PG*nunca são logadas); manter a linha-resumo no formatochave=valordo slog (gravadas=96 versoes-novas=0 ...), porque o README e o AI-USAGE citam esse formato.
Prova de equivalência (a mais importante)
make reset(banco zerado + carga) →make persist-mensal MES=2026-08com o serviço Go → foto A:SELECT id_dou, versao, orgao_id, data_publicacao, ementa, md5(conteudo) FROM ato WHERE origem='DOU' ORDER BY 1,2eSELECT nome, chave, fonte, status, orgao_id, motivo FROM orgao_alias ORDER BY fonte, nome(semid/criado_em, que mudam entre bancos). Esperado: 96 linhas emato, 751 emorgao_alias.make reset→ o mesmo com o serviço Python → foto B.diff A Bvazio; resumogravadas=96 orgao-desconhecido=8494 ignoradas=120 falhou=0 repetidas=0.- Prova cruzada (a mais forte): sobre o banco da etapa 1 (gravado pelo Go), rodar o persist Python: tem de dar
gravadas=0 versoes-novas=0 inalteradas=96eestado()idêntico antes/depois. Isso prova que ementa/conteudo/data chegam byte a byte iguais ao que o Go gravou; qualquer diferença de parsing virariaversoes-novas>0. - Guardar as fotos e os tempos em
docs/adr/medicoes/0008-linguagem-unica/e citar no AI-USAGE. Esforço: 10 h.
F4 — INLABS: descartar com ADR e tirar o Go do repositório
Decisão recomendada: não portar o cliente INLABS agora. Motivos verificados: o login automático é recusado (502 em logar.php, inclusive com o script oficial — AI-USAGE.md 12/10, README "Dado atrasado"); é o maior bloco do módulo (1.535 linhas = 33% do Go, 43 testes) e portá-lo sem poder exercitar contra o INLABS produziria código morto; a fonte automática real já é a mensal. O que se preserva: o contrato do bronze diário (data/bronze/inlabs/AAAA-MM-DD-DO1[E].zip + .sem-edicao/.falhou), que make extract DATA= e make persist DATA= continuam lendo via TEM_EDICAO — um ZIP baixado à mão pelo navegador continua entrando no pipeline. Manter os marcadores em bronze.py (já portados na F1) custa pouco e deixa o caminho pronto se o INLABS voltar a aceitar scripts.
Alternativa B (se a squad quiser manter a opção viva sem código): mover src/ingestor/ inteiro para um diretório archive/ não testado — não recomendo: viola "código no repo roda no make test". Alternativa C (manter Go só para o INLABS): mantém dois toolchains, duas suítes e o vet-go no CI por um comando que não funciona — é o custo que o ADR 0008 quer eliminar.
Remover: src/ingestor/ inteiro (restam cmd/fetch, cmd/fetch-mensal, internal/inlabs, internal/store, internal/dadosabertos, internal/config, go.mod, go.sum); serviços ingestor, ingestor-test (e fetch-mensal Go, se ainda existir), volumes gocache/gomodcache; alvos fetch, ingest, ingest-dia, test-go, vet-go; a linha make vet-go do CI; variáveis INLABS_* do .env.example (ou deixar comentadas com a referência ao ADR). Manter make janela e tests/ingest/test_janela.sh? O janela só existe para o ingest; sem ingest vira órfão. Proposta: remover ambos e registrar no ADR que a janela volta com o cliente diário; se a squad preferir manter a conta de datas para o ingest-dia manual, custa zero (já é Python no container carga). Substituir vet-go por vet-py: python -W error -m compileall -q src tests (stdlib) — e, se a squad aceitar uma ferramenta de dev, ruff check (o .gitignore já lista .ruff_cache/). CI: trocar a linha. README: seções "Baixar o DOU", "Gravar as matérias no banco" (trocar src/ingestor/internal/oltp por src/dou/oltp.py, remover "A chave é calculada em Go e em Python"), tabela de comandos, "Ingerir um mês". Esforço: 5 h (sem contar a discussão do ADR na squad).
F5 — ADR de linguagem única e limpeza
ADR 0008 (abaixo), índice em docs/adr/README.md, entrada no AI-USAGE.md, diário. Apagar scripts/equivalencia.sh temporário, se criado. Esforço: 2 h.
4. Estimativa de esforço (horas de uma pessoa)
| Fatia | Código | Testes | Prova/medição | Docs/PR | Total |
|---|---|---|---|---|---|
| F0 infra | 1,5 | 0,5 | – | 1 | 3 |
| F1 fetch-mensal | 3 | 3 | 1 | 1 | 8 |
| F2 extract | 3,5 | 2,5 | 1 | 1 | 8 |
| F3 persist | 3 | 4 | 2 | 1 | 10 |
| F4 INLABS + remoção do Go | 1 | 0,5 | 1 | 2,5 | 5 |
| F5 ADR 0008 + limpeza | – | – | – | 2 | 2 |
| Revisão de PR, retrabalho (~20%) | 7 | ||||
| Total | ≈ 43 h |
Cabe entre 14/10 e 03/11 para uma pessoa a meio tempo; F1–F3 são independentes de dbt/orquestração e podem andar em paralelo com a E3.
5. ADR: o que precisa virar decisão registrada
Dois pontos de decisão; recomendo um ADR com os dois, porque o segundo é consequência do primeiro: docs/adr/0008-unificar-o-pipeline-em-python.md (0005 está reservado para a #32; 0006 e 0007 existem). Rascunho de 10 linhas:
- Contexto. O pipeline tem dois toolchains: Go 1.26 no ingestor (
src/ingestor, 2,2 mil linhas, 112 testes, quatro serviços compose e três alvos de teste próprios) e Python 3.12 em planilha, conselhos e lake. A E3 traz dbt e orquestração, ambos em Python. A chave do órgão (ADR 0004) está em duas linguagens, com teste de paridade por arquivo. O cliente INLABS, metade do Go, não funciona desde 12/10 (login recusado a scripts); a ingestão real é a mensal dos dados abertos. - Alternativas. (A) manter as duas linguagens — opção nula: zero custo de migração, mas cada mudança na identidade dos conselhos exige dois PRs e dois toolchains no CI, e o orquestrador chamaria binários Go por shell; (B) tudo Python — reescrita de ~1 mil linhas úteis + testes, zero dependência nova (psycopg já está no lake), chave em um lugar só; (C) tudo Go — reescrever planilha, conselhos e lake (deltalake/pyarrow não têm equivalente Go maduro) contra o ADR 0003 e o 0006.
- Critério. Onde fica o código que a E3 vai orquestrar e que a pesquisadora vai manter; custo de manter a chave do órgão em dois lugares; o que já roda de verdade.
- Decisão. (B): o ingestor migra para
src/dou/em fatias (fetch-mensal → extract → persist), cada uma provada sobreS01082026.zipcontra o Go antes de o Go sair; o cliente INLABS é descontinuado sem porte, mantendo o contrato do bronze diário para ZIPs baixados à mão; o Go sai do repositório ao fim. - Consequências. Ganha-se um toolchain, uma suíte, uma chave; perde-se o
-racee ogo vet(substituídos por testes com threads ecompileall/ruff), o cliente INLABS com retry/sessão (recuperável do histórico) e, possivelmente, desempenho noextrair_texto(medido na F2 e registrado). - Gatilho de revisão. O INLABS voltar a aceitar login por script (reavaliar portar
internal/inlabscomurllib+cookiejar); o parser Python passar de N minutos por mês (medir; limite a decidir).
6. Riscos e armadilhas gerais
- Perda de cobertura. Go: 112 testes + 19 subtestes. Com o descarte do INLABS saem 50 (37
inlabs+ 6cmd/fetch+ 7config). Os 62 restantes têm porte 1:1 na tabela acima (18 store, 8 dadosabertos, 10 article, 3 extract, 14 oltp, 6 persist, 2 key já cobertos em Python). Conferir no PR de cada fatia a lista nome a nome; oTestSaveIsSafeWhenTwoRunsRaceOnTheSameNameprecisa de threads reais, não de mock. - Unicode na chave (#40). O Go só passou a bater com o Python depois de trocar categoria
Mnpor classe de combinação; os 2 caracteres divergentes não ocorrem em nome real. Com a migração a divergência entre linguagens desaparece, mas as 751 chaves emorgao_alias.chaveforam gravadas em parte pelo Go (626 do DOU) com tabelas Unicode dox/text, e o Python 3.12 usa Unicode 15.0: o testeTestKeyAgreesWithTheLoadedAliasesportado (recalcularchave(nome)para toda linha deorgao_alias) é a verificação; rodar também uma vez à mão contra o banco cheio de agosto antes de mergear a F3. Divergência aqui faria o persist enfileirar de novo um nome já pendente (chave diferente →waitingnão casa). - Tipos.
pubDateDD/MM/YYYY→date(nãodatetime);versaoéint;orgao_idint;ementaNonequando vazia só no SQL (NULLIF), como no Go, para a comparaçãoIS NOT DISTINCT FROMcontinuar valendo;conteudoé o<Texto>cru semstrip. - Timezone. Só o caminho diário depende de "hoje em Brasília" (
inlabs.Today); o Makefile já resolve comTZ=America/Sao_Paulo date +%Fno host, e o Python não entra nisso. Se o cliente diário voltar:zoneinfoexigetzdatana imagem slim. - Desempenho do parser. 8.590 XMLs/mês em
zipfile+ElementTreeé segundos; o gargalo provável éHTMLParsernoextrair_texto(só oextractprecisa dele; opersistgrava o HTML cru — não chamarextrair_textono persist, como o Go também não precisa, emboraparseo compute sempre: tornar o texto puro preguiçoso,@cached_property, é uma melhoria legítima que não muda a saída). Ler entrada a entrada, nuncaread()do ZIP inteiro (50 MB). - Idempotência. O statement único (
WITH versions ... INSERT ... WHERE NOT EXISTS) é o que garante "compara com todas as versões" e evita a corrida; copiar literalmente, não "reescrever em ORM". A prova cruzada da F3 (Go gravou, Python relê →inalteradas=96) é o teste de aceitação. - Ordem das entradas do ZIP. O caso "id repetido, vale a primeira" depende da ordem do diretório central (
ZipFile.infolist()preserva, comozip.Reader.File). Os testes constroem o ZIP com nomes ordenados. - Marcadores e
TEM_EDICAO. O Makefile lê.sem-edicao/.falhouporwildcard; sem ofetchdiário ninguém mais os escreve, mas os cinco.falhouque estão hoje emdata/bronze/inlabs/continuam fazendomake persist DATA=2026-10-04sair com erro — comportamento correto, documentar. - CI.
make testchamatest-goe o workflow chamavet-go; cada fatia que remove um alvo tem de ajustar os dois no mesmo PR, senão o CI quebra nomain. - Docs. README,
docs/diario, página "Jornada do dado" do site citamsrc/ingestor/...; o site VitePress serve os.mddo repo, então link quebrado derruba o build domake docs. - Git. O trabalho local está em HEAD desanexado sobre commits de CDC não mergeados (
5b08554); abrir as branches das fatias a partir deorigin/maine rebasear quando o CDC entrar.