Operação¶
Esta página é para quem opera a máquina no dia a dia: quais workers existem, em que ordem rodam, como subir o runner ou disparar uma execução pontual, como a configuração efetiva é resolvida, o que o painel de sistema mostra e o que fazer quando algo quebra. Instalar e configurar o sistema está em Instalação e implantação; as decisões arquivísticas de cada tela estão em Curadoria.
Os workers e a ordem do pipeline¶
src/scrinalia/domains/archive/workers/catalogue.py é a definição única de um worker: a posição
dele no pipeline, a unidade que a fila conta, se ele respeita a governança de revisão e o carimbo
que escreve. O catálogo não importa nenhum worker — resolve cada módulo sob demanda —, então lê-lo
é barato.
Dez workers, na ordem do pipeline:
| # | Nome | Módulo | Unidade | Governança | Engine efetiva vem de |
|---|---|---|---|---|---|
| 1 | ingestion |
ingestion.worker |
origem | não | — |
| 2 | transfer |
worker_archive_transfer |
registro de staging | não | — |
| 3 | cleaning |
worker_cleaning_regex |
documento | sim | — |
| 4 | ner |
worker_ner |
documento | sim | assinatura |
| 5 | typology |
worker_typology |
documento | sim | assinatura |
| 6 | thumbnail |
worker_thumbnail |
documento | sim | — |
| 7 | conflict-judge |
worker_resolve_tag_entity_conflict |
par tag × entidade | não | assinatura |
| 8 | macro-category |
worker_macro_category |
tag | não | assinatura |
| 9 | quality-validator |
worker_quality_validator |
documento | sim | regra LLM_CHECK ativa |
| 10 | embedding |
worker_embedding |
documento | não | assinatura |
A ordem é a ordem em que as etapas liberam umas às outras, e é a ordem que a seção de pipeline do painel mostra:
ingestion -> transfer -> cleaning -> ner -> typology -> thumbnail -> conflict-judge -> macro-category -> quality-validator -> embedding
ingestioné a etapa à frente do Acervo: lista uma origem, lê as páginas de detalhe para oraw_datae estrutura os registros novos emstaging_documents. A unidade dela é a origem, não o documento, e ela não tem engine; cada origem é uma linha com a sua própria fila, agenda e configuração, descrita em as origens da ingestão. Os comandos de linha de comando abaixo continuam existindo para uma execução avulsa, mas o painel é o caminho de operação.transfercopia o registro estruturado do staging para a descrição do Acervo, tendo como chave o hash do registro interpretado (staging_content_hash). É a única etapa que reescreve conteúdo: quando o hash muda, oexecution_loggravado é zerado e os enriquecimentos de IA precisam rodar de novo. Uma descrição que uma pessoa já aprovou nunca é sobrescrita, e um registro de staging com o mesmo hash não é gravado.cleaningaplica as regras de limpeza ativas do tipoREWRITE, uma passada por regra. Uma regra do tipoVALIDATEouLLM_CHECKnunca substitui nada — sinalizar é papel do validador de qualidade.nerextrai nomes próprios, lugares e instituições do texto composto da descrição.typologyclassifica cada descrição contra o catálogo de tipologias ativas por zero-shot. Uma descrição cujo melhor score fica abaixo do piso mantémtypology_idNULL — a coluna diz isso tanto quando o classificador recusou quanto quando nenhuma execução chegou à descrição — então a recusa é gravada: uma linha emarchive_ai_review_queuecom o score que a execução viu.thumbnailbaixa a imagem original e guarda uma miniatura no storage de objetos. Ele confirma que o bucket existe antes do primeiro documento — criando-o só quandoS3_AUTO_CREATE_BUCKET=true— então um bucket ausente derruba a execução uma vez, com uma frase, em vez de carimbar a fila inteira com oAccessDeniedenganoso do endpoint. O--limit Nlimita os documentos de uma execução, que é o que torna barato um teste local. O worker só escreve o objeto; a API o serve sob demanda emGET /api/v1/documents/{description_id}/thumbnail(ADR 0017). O endpoint precisa continuar alcançável a partir do processo da API e as credenciais precisam permitirGetObject— o navegador nunca fala com ele.conflict-judgecompara o vocabulário de tags com o de entidades e consulta o LLM local para decidir se o termo ambíguo é assunto ou nome próprio. Todo veredicto, automático ou enviado a uma pessoa, deixa uma linha emarchive_ai_review_queue. Uma chamada que o motor não conseguiu fazer levanta em vez de virar veredicto, então o par não deixa linha e é perguntado de novo na execução seguinte: ele continua pendente, e o número de "já julgados" do painel não o conta.macro-categoryarquiva cada tag numa categoria de assunto. A unidade dele é a tag, não a descrição, então o carimbo vive noexecution_logda própria tag; uma tag que o curador arquivou à mão está fora do alcance dele.quality-validatormarca anomalias estruturais, títulos repetidos e regrasVALIDATE. Nunca reescreve um campo. O modelo de linguagem só é construído quando existe uma regraLLM_CHECKativa.embeddinggrava o vetor usado pela busca semântica e lê o texto por último, porque toda mudança de texto precisa acontecer antes dele.
O embedding é a exceção de governança documentada
embedding de propósito não usa a proteção de HUMAN_APPROVED. O vetor é um índice derivado do
texto, não conteúdo arquivístico, então uma descrição aprovada cujo texto mudou precisa ser
re-embedada, ou a busca semântica serviria um vetor desatualizado. Ele grava apenas a coluna
embedding e o próprio carimbo, e pula descrições em REJECTED.
O que cada fila significa¶
Uma fila não é uma tabela: é o predicado que o próprio count_pending do worker compartilha com o
execute. O painel nunca o re-deriva. pending é quantas unidades a próxima execução leria.
Para os cinco workers cujo livro é um carimbo, o painel abre o carimbo em vez de mostrar um número
só, porque um slot de identidade pode guardar três coisas diferentes e apenas uma delas atribui a
resposta: com produtor é um valor que o próprio padrão do slot aceita, uma impressão digital que
realmente nomeia o modelo; sem produtor conhecido é um valor legado — o "DONE" gravado antes de
a impressão digital existir, ou o PRODUCER_UNKNOWN que uma migração deixou no lugar dele; sem
registro é a chave ausente, ou seja, nada registrou execução alguma. processed é os dois
primeiros mais as falhas, e failed conta os carimbos que registram uma (ERROR; a falha do
thumbnail vive sob a chave thumbnail_failed, porque o livro dele não é um carimbo).
Os quatro números aparecem em Carimbos (descrições), ao lado da fila e não embaixo dela, porque
respondem a uma pergunta diferente da de pending: o detalhamento é sobre o livro inteiro do
worker, enquanto pending é o predicado da fila, e os dois conjuntos não são o mesmo. Na coleção de
referência o card do NER lê 4.831 carimbados contra 4.813 na fila, e a diferença são as
18 descrições que uma pessoa aprovou — a fila aplica ai_writable_documents(), então uma
descrição curada é contada no livro e nunca na fila. Quem lêsse o detalhamento como decomposição da
fila via 4.831 onde a fila dizia 4.813 e não tinha onde pôr a diferença.
Cinco dos nove carimbos carregam a identidade do produtor em vez de um status: uma impressão
digital da engine, do preset, de toda a configuração resolvida da engine, das versões instaladas das
bibliotecas daquele eixo e das opções comportamentais declaradas (force, limit e o config
reservado ficam de fora). A consequência é o ponto do desenho: mudar qualquer uma dessas coisas
devolve as unidades afetadas à fila sozinho, o que um DONE nunca poderia fazer — um bump do
torch invalidava todos os vetores enquanto o carimbo continuava atual e a fila continuava vazia. O
embedding compõe a identidade com o MD5 do texto efetivo, então uma edição humana também o
re-enfileira; o macro-category compõe com o hash do conjunto de rótulos. Dois produtores ficam
deliberadamente fora da identidade, porque lê-los custaria um import pesado ou uma chamada de rede no
caminho do painel: a versão do pacote do modelo spaCy (pt_core_news_lg) e o digest do modelo que o
servidor Ollama serve. Rode esses dois casos com force.
| Worker | O que está pendente | Carimbo de idempotência |
|---|---|---|
transfer |
registros de staging sem linha no Acervo, ou cujo hash do conteúdo interpretado difere do gravado — exceto descrições que uma pessoa aprovou ou rejeitou | nenhum; um hash do registro de staging, mais hierarchy_parent_v1 para um pai declarado |
cleaning |
documentos que as regras REWRITE ativas ainda não carimbaram; um documento pendente para duas regras conta duas vezes |
cleaning_rule_{rule_id} |
ner |
documentos com texto em ao menos uma coluna extraída e sem o carimbo do produtor atual | worker_ner_v2 (o valor é a identidade do produtor) |
typology |
documentos com texto e sem o carimbo do produtor atual — o carimbo assina o conjunto de tipologias ativas, então acrescentar, renomear ou aposentar uma tipologia alcança os documentos já classificados | worker_typology_classifier_v2 (o valor é a identidade do produtor) |
thumbnail |
documentos com imagem de origem, sem URI no storage e sem marca de falha — todos os marcados, com force |
nenhum; a coluna storage_thumbnail_uri, mais a marca thumbnail_failed |
conflict-judge |
não é mensurável barato: a fila é o produto trigram de tags × entidades, medido em 53 s no acervo real. O painel mostra o que já foi julgado | nenhum; a linha em archive_ai_review_queue |
macro-category |
tags sem categoria de assunto cujo carimbo não é o produtor atual (o conjunto de rótulos e o modelo estão nos dois) | worker_macro_category_v1 (na tag; o valor é o conjunto de rótulos composto com a identidade do produtor) |
quality-validator |
documentos sem o carimbo do produtor atual, a menos que force esteja ligado |
worker_quality_validator_v1 (o valor é a identidade da regra LLM_CHECK ativa, ou de "sem modelo" — inclusive quando a engine da regra não pode ser construída, que é a execução que o validador de fato faz) |
embedding |
documentos cujo carimbo difere do MD5 do texto efetivo mais a identidade do produtor — a primeira execução, toda mudança de texto posterior e todo bump de modelo ou dependência | worker_embedding_v1 (o valor é o MD5 seguido de : e da identidade) |
Um documento que o worker marcou como ERROR fica fora da fila comum: force é o caminho de volta,
e ele limpa a marca nas unidades que processa. Uma unidade carimbada por um produtor que não está
mais configurado volta a estar pendente — incluindo todo valor legado, leia ele ainda "DONE" ou o
PRODUCER_UNKNOWN para o qual a migração o renomeou. Essas linhas estavam pendentes antes da
renomeação e continuam pendentes depois dela, então a migração mudou o que o livro diz e não o que
a fila faz: a próxima execução as cura gravando uma impressão digital de verdade. É esse o ponto
da distinção — atribuição e relato, não a fila. A identidade da tipologia também assina o conjunto de
tipologias ativas, então depois desta atualização toda descrição carimbada fica pendente uma vez,
com ou sem tipologia: o acervo inteiro editável pela IA na primeira execução e, daí em diante, só o
que uma mudança de catálogo, modelo, coluna ou piso realmente moveu. A pré-visualização abaixo é como
medir isso antes de pagar.
O contador do transfer é o mais pesado: valida a tabela de staging inteira pelo mesmo port que o
transfer usa. É lido uma vez por carga do painel e não é consultado em laço.
O runner¶
Desde a ADR 0014 os workers rodam no próprio processo; a API apenas enfileira. O runner reivindica as
linhas QUEUED e as executa:
uv run python -m scrinalia.domains.archive.workers.runner_daemon \
--name gpu-01 --workers ner,typology,embedding
| Flag | Significado |
|---|---|
--name |
o nome humano no painel; o identificador é gerado (name:<uuid curto>) a menos que --id seja dado |
--workers |
os workers que este runner aceita, separados por vírgula; o padrão é o catálogo inteiro |
--concurrency |
quantas execuções ele executa de uma vez; 1 por padrão, porque os modelos são limitados por CPU/GPU |
--heartbeat-seconds, --lease-seconds |
o lease que ele renova e quanto tempo uma reivindicação sobrevive sem ele; 15/60 por padrão, e o heartbeat precisa ser menor |
--poll-seconds, --sweep-seconds |
de quanto em quanto procura trabalho e expira reivindicações; 2/30 por padrão |
--once |
reivindica e executa no máximo uma execução, e sai |
--drain-seconds |
espera até esse tempo pelas execuções em andamento antes de sair; o lease cobre o resto |
O runner se registra em archive_worker_runners, renova a própria linha com o mesmo heartbeat e
recusa um schema atrás do seu código. O painel o mostra — online/offline, o host, a versão, as
capacidades — e marca sem runner os workers que nenhum runner online aceita; uma execução
enfileirada para um deles espera. O banco é o único canal: a API nunca chama um runner.
Executando um worker¶
O runner unificado¶
Prefira o runner unificado, que expõe todos os workers por uma interface só. Rode da raiz do repositório:
uv run python -m scrinalia.domains.archive.workers.runner <name> \
[--engine X --preset Y --batch N --limit N --option key=value --engine-option key=value --by "quem"]
Por exemplo:
# Classifica a tipologia de toda descrição pendente, em lotes de 100.
uv run python -m scrinalia.domains.archive.workers.runner typology \
--engine deberta_typology --preset cpu_local --batch 100 --by "ana"
# Testa um limiar medido pelo bench só nas primeiras 200 descrições.
uv run python -m scrinalia.domains.archive.workers.runner typology \
--option confidence_threshold=0.6 --limit 200 --by "ana"
# Força o embedding de toda descrição, ignorando o hash do texto.
uv run python -m scrinalia.domains.archive.workers.runner embedding --option force=true
# Sobrescreve o device da engine só nesta execução: um repasse ao construtor da engine.
uv run python -m scrinalia.domains.archive.workers.runner ner \
--engine spacy_ner --engine-option device=cpu --by "ana"
| Flag | Significado |
|---|---|
<name> |
um dos nove nomes acima; qualquer outro valor é recusado com a lista de opções |
--engine X |
uma engine registrada do eixo do worker, por exemplo spacy_ner, deberta_typology, ollama_judge |
--preset Y |
um preset desse eixo, por exemplo gpu, lemmatizer, cpu_local, gpu_cloud, xlm_roberta_local, gemma_4b_local, multilingual_minilm |
--batch N |
tamanho do lote por transação, repassado aos workers que declaram db_batch_size; um worker sem isso registra no log que o valor foi ignorado |
--limit N |
teto de unidades desta execução, para os workers que declaram limit (embedding, macro-category, ner, quality-validator, thumbnail, typology); recusado nos demais, porque um teto que não faz nada em silêncio é pior do que nenhum teto. Ele conta tentativas, então uma fila de falhas ainda termina, e nunca é persistido |
--option key=value |
uma opção declarada do worker, repetível; uma chave que o worker não declara é recusada com a lista de opções dele, e key sem = é recusado |
--engine-option key=value |
repasse cru ao construtor da engine, repetível, para uma execução; nunca persistido, e as chaves não são enumeradas (a engine valida o valor) |
--by "quem" |
texto livre com quem está executando; a CLI roda no host e não tem sessão, então isso continua sendo um nome sem conta por trás |
O --option é validado contra a assinatura do execute do worker e convertido pelo tipo que ele
declara ali: true/false viram booleanos, um default inteiro faz o valor virar int, um default
float faz virar float. --option confidence_threshold=0.6 portanto chega como número e alcança a
comparação; um valor que não pode ser lido (0.6x para um float, talvez para um booleano) é
recusado com uma frase antes de a execução começar, em vez de falhar no fundo dela. Uma chave que o
worker não declara é recusada: --option colunas_to_classify=… — o typo medido na #120 — vira uma
frase de 422 com as opções que o worker aceita, e não um padrão que falha depois no contador da
fila. Uma lista de colunas é conferida contra as colunas que o texto da IA tem (abaixo).
O --engine-option é a exceção nomeada: vai direto ao construtor da engine, para quem conhece a
biblioteca. Vale por execução e nunca é persistido, então um ajuste não vira padrão da instalação; as
chaves que o próprio eixo repassa são oferecidas como dica (model, device, host, batch_size,
dimensions, disable), e uma chave fora delas continua sendo repassada — a engine é quem valida o
valor. Só um worker cujo execute termina em **engine_kwargs tem a porta; transfer, cleaning,
thumbnail e conflict-judge não declaram repasse, e a execução é recusada se um for dado. O
conflict-judge, portanto, tira o host da configuração OLLAMA_HOST_URL (ou do default local), não
de uma flag.
Cinco coisas são sempre recusadas:
configé um dataclass do runner (NerRunnerConfige os irmãos), não um valor que uma linha de comando ou um corpo JSON possa carregar; um dicionário chegando aoexecutefalharia com erro de atributo em vez de uma frase. Passe os campos individuais.sourceestoragesão portas (a origem do staging, o armazenamento de objetos), edb/db_sessioné a sessão que o runner abre: nenhuma delas viaja por JSON.engine_name,presetedb_batch_sizetêm campo próprio. São recusados dentro deoptionsporque o runner aplica o mapa de extras depois do argumento resolvido, e um valor ali era uma segunda definição silenciosa do mesmo campo.limité recusado como padrão persistido (PUT …/settings): ele limita uma execução, e um teto que sobrevivesse ao comando pararia toda execução seguinte em N unidades.forceé um padrão legítimo — é o caminho de volta de uma marca que uma queda passageira deixou.--engine/--presetsó fazem sentido para os workers cuja configuração vem da própria assinatura (ner,typology,conflict-judge,macro-category,embedding).transfer,cleaningethumbnailnão carregam modelo, equality-validatortira a engine da regraLLM_CHECKativa.
Toda execução pelo runner grava uma linha em archive_worker_runs (veja
o ledger de execuções).
Executando o módulo de um worker diretamente¶
Cada worker_*.py também traz uma função execute(db, ...) e um bloco __main__, então um worker
isolado pode ser rodado durante o desenvolvimento:
uv run python -m scrinalia.domains.archive.workers.worker_ner
Uma execução direta é ferramenta de depuração, não o caminho de operação. Ela ignora o ledger, os
overrides persistidos, a trava de concorrência e a conversão de --option, e usa o engine_name e
o preset que o bloco __main__ do módulo fixa (para ner, db_batch_size=100 e, no resto, os
padrões da assinatura). Nada aparece no histórico de execuções, então nada avisa que ela aconteceu.
As origens da ingestão¶
Uma origem é uma definição em código mais uma linha em ingestion_origins (ADR 0016). A definição —
o adaptador e o SourceSchema que diz qual rótulo dela preenche qual coluna do staging — fica ao
lado do adaptador; a linha é o ciclo de vida: habilitada/pausada, as URLs, a política de execução que
uma execução agendada herda e o cron/fuso da agenda. As definições manual e csv são formas de
entrada, não fontes: não têm adaptador e nunca são oferecidas pelo seletor. As linhas delas existem
para a proveniência e para o formulário/importação, e a tela as mostra só para leitura. Adicionar uma
origem é um módulo e uma entrada no registro; a linha é adicionada pela tela, e o transform de
staging não aprende site nenhum.
/sistema/ingestao é a tela, e ela tem duas seções (#206). Origens lista as fontes configuradas,
um cartão cada: a fila que a próxima execução leria, os números de processados/falhas, a última
execução e a ativa, a agenda, a configuração efetiva e a disponibilidade de runner. O operador pode
Adicionar origem — um seletor das definições que o pacote traz e esta instalação ainda não tem
linha; adicionar ativa —, Configurar, Agenda, Desativar/Ativar e Enfileirar execução.
Configurar e Agenda são os dois painéis do cartão: o clique abre o cartão, mostra o formulário
e o traz para a vista, e um segundo clique fecha o formulário e deixa o cartão aberto.
Pausar é o ciclo de vida, não uma remoção: o cartão, a agenda e as execuções ficam, e só a execução é
recusada. Uma instalação nova começa sem fonte configurada (a linha pmc semeada é apagada por
f1090f825a94), então a seção mostra Nenhuma origem configurada e o fluxo de adicionar.
Entradas lista as linhas manual e CSV só para leitura, com o status e um link para
/acervo/nova: elas são as formas de entrada, não fontes.
As duas formas de entrada do acervo são as abas de /acervo/nova (#205). As rotas genéricas de
worker recusam ingestion: uma execução dela precisa da origem, então as rotas da origem são o
seletor (/api/v1/ingestion/origins/...).
O cartão é OPERATE: o operador adiciona, configura, agenda, pausa e executa. As rotas de leitura
continuam autenticadas, então um curador que segue uma URL direta ainda vê os cartões só para
leitura, com os controles de operador escondidos; a porta comum do acervo é /acervo/nova.
A etapa roda no mesmo ledger, runner, lease e cancelamento dos workers de IA. Uma execução tem
worker_name="ingestion" e origin_code preenchido; o índice parcial único é
(worker_name, COALESCE(origin_code, '')), então os workers de archive mantêm uma vaga por worker
enquanto cada origem tem a sua. O agendador lê as origens devidas e enfileira uma execução por
origem; um período que chega com aquela origem já rodando é pulado, e uma ingestão agendada que
falha é retentada pela política existente.
Os comandos de linha de comando continuam para uma execução avulsa, e agora sempre nomeiam a origem:
# Lista os identificadores da origem na fila, percorrendo no máximo dez páginas.
uv run python -m scrinalia.domains.ingestion.worker discover --origin pmc --pages 10
# Lê a página de detalhe de no máximo cem dos identificadores que a fila guarda.
uv run python -m scrinalia.domains.ingestion.worker scrape --origin pmc --limit 100
# As duas fases e o transform de staging num processo.
uv run python -m scrinalia.domains.ingestion.worker run --origin pmc --pages 10 --limit 100
# Estrutura registros brutos no staging; sem --origin, cada registro resolve o próprio schema.
uv run python -m scrinalia.domains.staging.worker --limit 100
--origin é obrigatório nos comandos de ingestão e continua não havendo padrão: o
ACERVO_SOURCE foi removido. O adaptador de referência aceita PUBLIC_SCRAPE_URL e
PUBLIC_SCRAPE_DETAIL_URL como fallback das URLs, que é o que mantém um .env existente
funcionando; a configuração da origem vence quando tem valor. O scrape também aceita
--audit-ttl-days N para reler as descrições concluídas há mais de N dias, --force-retry-fatal para
incluir as linhas marcadas como FATAL_ERROR, --ignore-sliding-window para descartar as janelas de
descoberta e de TTL, e --max-retries, --discovery-window-days e --scraped-ttl-days para
sobrepor a política daquela execução.
Uma execução limitada retoma sozinha. A fila é ordenada por last_scraped_at, com as nunca
tentadas na frente, e todo desfecho — concluída, falha transitória, falha fatal — carimba esse
campo, então o lote que acabou de rodar vai para trás dos que ainda não rodaram. O transform tem
como chave o hash do registro interpretado, então o que uma execução estruturou deixa de estar pendente.
--limit 100 sobre uma fila de milhares pega os próximos cem na execução seguinte, e os dois
comandos registram no log quanto ainda falta quando terminam. A única exceção é uma leitura cuja
gravação sofre rollback: a linha mantém seu lugar e ocupa uma vaga em toda execução até que a
gravação dê certo.
--force reinterpreta todos os registros brutos, que é o que uma correção de parser exige, e
também ignora a comparação de hash da qual o transform avança, então uma execução forçada e
limitada não retoma sozinha. É para isso que existe --after <description_id>: a execução registra
no log o id em que a janela terminou e o comando para continuar dali. Não use --after sem
--force: sob a comparação de hash, pular à frente pularia registros pendentes em vez de registros
já estruturados.
A entrada manual¶
A origem manual é a aba Digitar em /acervo/nova (o caminho antigo /sistema/ingestao/nova
redireciona para lá), e ele não é uma aprovação. O envio grava o raw_data (com created_by),
uma linha em scraping_queue marcada como DONE (o formulário é a descoberta) e o registro de
staging; em seguida enfileira o mesmo worker transfer que a coleta usa. A descrição do Acervo nasce
PENDING_AI, e as filas de IA a veem; o curador aprova no dossiê depois que a IA rodar. Se já houver
uma transferência em andamento, o registro fica no staging e a resposta diz que ele entra na próxima.
Um description_id duplicado é recusado antes da gravação; o formulário gera um id manual-<uuid>
para que um reenvio seja o mesmo registro.
O formulário é a primeira aba da tela de entrada, em /acervo/nova; a importação CSV é a segunda. O
registro é o registro ISAD(G) inteiro — trinta controles agrupados nas sete áreas da norma, com a
identidade aberta e o resto recolhido — e divide a tela com a importação porque as duas alimentam o
mesmo staging e a mesma fila, não porque são a mesma entrada. Os rótulos, os tipos de controle e a
ordem vêm do mesmo catálogo que o dossiê edita (apps/curator/src/lib/isad.ts), então as duas telas
não podem criar dois vocabulários para uma norma.
Três comportamentos valem saber antes de digitar:
- A data é texto. O arquivista digita o que o registro diz (
Década de 1980,1951-1953) e o perfil de idioma interpreta — a mesma gramática que a importação CSV usa. Um valor que o perfil não consegue ler é recusado com uma frase, em vez de virar data vazia, e a confirmação repete a data que o perfil leu, para que o que foi gravado fique visível. - O nível e o idioma são seletores. O nível vem do catálogo de níveis e o idioma de
GET /api/v1/languages(um catálogo declarado em código — um idioma não carrega regra que a instituição edite). Um nível que exige pai e não tem nenhum avisa e grava assim mesmo: o registro entra pendente e a aba Arranjo do dossiê faz o vínculo depois. - O objeto digital está desabilitado. O controle de upload aparece desabilitado, com a frase que aponta para a #198: o acervo guarda a descrição, não o arquivo. A URL da imagem logo abaixo é o caminho que funciona hoje — o worker de miniatura copia essa imagem para o storage.
A importação CSV¶
A origem csv é a aba Importar CSV em /acervo/nova?modo=csv, e é uma escrita do mesmo tipo da
entrada manual: as linhas entram no staging e uma transfer é enfileirada, então a descrição do
Acervo nasce PENDING_AI (#191, ADR 0019).
O arquivo nunca chega ao servidor como arquivo. O navegador o interpreta (vírgula ou ponto e vírgula,
campos entre aspas, CRLF, UTF-8 com fallback para Windows-1252), mapeia os cabeçalhos detectados para
os rótulos declarados da origem e mostra uma prévia; só uma lista limitada de linhas é enviada como
JSON para POST /api/v1/ingestion/csv. Não existe rota multipart nem storage de objetos neste caminho:
uma instalação cujo bucket de miniaturas não está configurado importa um CSV do mesmo jeito.
GET /api/v1/ingestion/csv/spec publica o mapa e os limites — o rótulo, o atributo de staging, o
caráter obrigatório e o teto de cada coluna, além de max_rows, max_bytes, max_row_bytes e
max_field_chars. Os valores vivem em domains/ingestion/csv_import.py, uma definição só; a rota
recusa um pedido acima deles com 413 e uma frase.
A API é a autoridade, não a tela: cada linha é validada com o mesmo StagingDocumentDTO e o mesmo
SourceSchema que o transform de staging usa, e a resposta reporta cada linha como aceita,
duplicada (o description_id já existe em raw_data, scraping_queue ou no Acervo) ou
inválida (título ausente, data ilegível, campo desconhecido, valor acima do limite), com a linha do
arquivo e o campo. Uma importação parcial é um desfecho normal, não um erro. As linhas aceitas são
gravadas com origin_code='csv' e estruturadas numa execução só; se já houver uma transferência em
andamento, a resposta diz que elas entram na próxima. Reimportar um arquivo sem a coluna
Identificador cria registros novos — a coluna é opcional e a tela gera um csv-<uuid> quando ela
falta.
Nenhum dos dois comandos escreve em archive_worker_runs — o ledger é do runner — e nenhum dos
dois aparece no painel de sistema, o que é uma mudança à parte.
Configuração: o que uma execução realmente usa¶
A configuração efetiva segue uma precedência:
argumento explícito > linha em archive_worker_settings > o padrão da assinatura do worker
A linha persistida (archive_worker_settings, uma por worker, com chave worker_name) é parcial
de propósito: um campo deixado NULL continua seguindo o código, então um preset renomeado numa
versão nova ainda alcança todo worker que ninguém sobrescreveu. Seus campos são engine_name,
preset, db_batch_size, options, updated_by e updated_at.
Toda escrita deixa uma revisão em archive_worker_settings_revisions (before, after,
changed_by, changed_by_user_id, changed_at), gravada na mesma transação da linha. Você pode
lê-las em GET /api/v1/system/workers/{worker_name}/settings/revisions. Apagar a linha
(DELETE /api/v1/system/workers/{worker_name}/settings) faz o worker voltar a seguir o código, e é
idempotente.
GET /api/v1/system/workers é a forma de ver a configuração efetiva sem rodar nada. Cada worker
traz o engine_name, o preset, o db_batch_size e as options resolvidos, o overridden (se
existe linha — um campo pode ser None de propósito), o dicionário config resolvido, os
available_engines com seus presets, o declared_options que o worker aceita e o
engine_option_names que o eixo dele sabe repassar, e uma note. Esse dicionário vem do
describe_config de cada registro de engine, o gêmeo somente-leitura do get_engine: resolve o preset e os overrides e
devolve o dicionário sem instanciar a engine, então o painel consegue responder "com qual modelo
isto vai rodar?" para um worker que nunca rodou — sem carregar o modelo.
Para quality-validator, o engine_source é llm_check_rule: a engine e o preset vêm da regra
LLM_CHECK ativa, e as rotas de configuração e de disparo recusam defini-las ali, porque um segundo
lugar para escolher a mesma engine seria uma segunda fonte de verdade. Sem regra ativa, o worker roda
apenas a validação determinística, e o painel diz isso na note.
O que um worker aceita¶
O que um worker aceita é a assinatura do execute dele, e a API publica isso: GET
/api/v1/system/workers e /workers/settings trazem declared_options — o nome, o tipo de controle
(boolean, integer, number, text, list, columns), o default da assinatura, se a opção é um
padrão persistido ou de uma execução (scope: "run" para limit) e, para uma lista de colunas, os
valores que ela admite. As telas de configuração e de execução renderizam essa lista como campos, então
o formulário não pode oferecer uma chave que a escrita recusaria, e o arquivista não precisa saber que
o parâmetro se chama columns_to_classify.
Dois nomes de opção carregam o texto que um worker de IA lê: columns_to_extract (ner) e
columns_to_classify (typology). Os valores deles são as colunas de AI_TEXT_COLUMNS:
original_title, final_title, scope_content, admin_bio_history, provenance.
Essa tupla é a definição única do que a IA pode ler: é o que a composição em SQL compõe, o que os
benches de avaliação conferem em --config engine:preset:colunas e o que a escrita confere na opção.
Um nome fora dela é recusado quando o padrão é salvo, com as conhecidas na frase; uma lista vazia
também é recusada, porque um worker sem coluna não tem texto para ler. Deixar o campo vazio mantém o
default do código (NerRunnerConfig.columns_to_extract,
TypologyRunnerConfig.columns_to_classify), que é o valor que o ledger da execução registra.
Uma linha salva antes desta regra não é um erro novo: a leitura ignora a opção que não consegue mais
honrar e põe o motivo em note ("Padrão salvo ignorado — …"), então o painel responde com o default
do código em vez de derrubar o contador da fila. Salvar a tela de novo cura a linha.
Testar uma configuração antes de salvá-la¶
Um padrão salvo não é só uma preferência: como a fila é chaveada pela identidade do produtor, mudar a
engine, o preset ou uma opção declarada (um confidence_threshold, as colunas lidas) devolve
unidades à fila sozinho. POST /api/v1/system/workers/{worker_name}/queue/preview responde quantas,
para uma configuração que nunca foi escrita, com a mesma precedência que uma execução usa. O corpo é o
corpo da configuração, então a tela pré-visualiza exatamente o que salvaria, e a resposta é pending
mais um pending_reason para o worker cuja fila não é mensurável (conflict-judge).
Medido no acervo de referência, com os documentos da tipologia carimbados pelo produtor que está no
código: a configuração padrão responde 0 na fila, e propor {"confidence_threshold": 0.9} responde
4.465 — o limiar faz parte da identidade, então os documentos que o limiar de 0.40 settleou estão
pendentes sob o de 0.90, e nada foi salvo. Esse número é também o que a próxima execução custaria, e é
por isso que a tela de configuração o coloca atrás de Conferir impacto em vez de deixá-lo ser
descoberto depois da escrita.
A CLI não tem rota de pré-visualização; --limit N numa execução é a outra forma de limitar o mesmo
custo.
Comparar configurações numa amostra¶
A prévia responde quantos a configuração leria. Ela não responde se os rótulos estão certos, e é essa a pergunta que produziu a #70: a tipologia rodou sobre 4.844 descrições com uma configuração que ninguém mediu, e rodar de novo é caro. Um ensaio responde sem escrever no acervo (ADR 0018).
POST /api/v1/system/workers/{worker_name}/trial recebe o tamanho da amostra, de duas a quatro
configurações (cada uma com a mesma forma engine_name/preset/db_batch_size/options de uma
execução) e uma semente opcional, e enfileira uma linha no ledger com trial=true. O runner
executa as configurações em sequência dentro dessa linha, sobre a mesma amostra, e grava o resultado
no trial_result dela. Um ensaio:
- não escreve conteúdo arquivístico — nem
typology_id, nem carimbo, nem linha na fila de revisão. O executor compartilha a composição do texto, o registro de engines e a regra de decisão (score > confidence_threshold) do worker, mas nunca chamaexecute; - ocupa a vaga do worker, porque carrega o modelo e lê o acervo, e o painel o mostra como
ensaio; a "última execução" do painel é uma execução de produção, nunca um ensaio; - é cancelável como qualquer execução, e nunca é retentado.
Duas populações são medidas, e são diferentes de propósito:
- o gabarito é a decisão humana já registrada: a última linha de
archive_document_revisionsque mudoutypology_id(o dossiê, ou o arquivamento de uma recusa), e o nome atual do catálogo para o qual ela aponta.HUMAN_APPROVEDnão é veredito — aprovar um registro não é certificar cada rótulo dele. A concordância (a fração de vereditos que a configuração acertou, com o denominador visível) e a cobertura (a fração que ela respondeu em vez de abster) são medidas aqui; - o escopo de produção é uma amostra aleatória semeada das descrições ai-writable que as configurações leriam (a interseção dos predicados sem o carimbo). A distribuição, a abstenção, a entropia e os segundos por documento são medidos aqui, porque o gabarito é um conjunto difícil e não a mistura que a próxima execução vai pagar.
O engine é construído uma vez por (engine, preset, opções do engine) e compartilhado, então uma
configuração que só muda o limiar não paga o carregamento do modelo de novo. Uma instalação nova não
tem veredito humano de tipologia e a tela diz isso em vez de imprimir uma concordância sobre um
conjunto vazio. Uma tipologia aposentada do catálogo ativo é um erro garantido (o engine só responde
nomes ativos) e o resultado a nomeia.
A comparação produz a escolha; ela não vira a escolha. Usar esta envia o corpo settings da
própria configuração — o que o ensaio mediu — para PUT /api/v1/system/workers/{worker_name}/settings,
então o padrão continua vivendo numa linha só. Rodar no acervo inteiro então lê o pendente do
corpo salvo (queue/preview, depois da escrita) e dispara a execução real com um request vazio,
para que a precedência resolva a linha que acabou de ser salva: o número na tela é o número que a
execução vai ler.
limit e engine_options são recusados dentro de uma configuração de ensaio: o primeiro é um teto
de uma execução e o segundo é um repasse cru ao construtor do engine, e nenhum dos dois é padrão
persistido — a comparação tem de ser de algo que a tela consegue salvar. Hoje o ensaio existe para o
worker de tipologia; a rota recusa os outros, e a tela de configuração só oferece o cartão onde o
catálogo diz trial.
O painel de operação¶
O painel é a superfície /api/v1/system/* e os quatro cartões de /configuracoes, sob Operação, na
SPA: o menu não os carrega mais (issue #22).
| Rota | Método | Permissão | O que faz |
|---|---|---|---|
/api/v1/system/workers |
GET |
autenticado | os dez workers com configuração, números de fila, última execução/execução ativa e o registro de runners, numa requisição só |
/api/v1/system/workers/settings |
GET |
autenticado | os padrões persistidos dos workers de IA, sem os contadores de fila (issue #53) |
/api/v1/system/workers/{worker_name}/queue/preview |
POST |
autenticado | o pendente de uma configuração proposta, calculado sem escrever |
/api/v1/system/workers/{worker_name}/runs |
POST |
OPERATE |
enfileira uma execução com overrides só para ela e responde 201 na hora |
/api/v1/system/workers/{worker_name}/trial |
POST |
OPERATE |
enfileira um ensaio: duas ou mais configurações sobre uma amostra, sem escrever conteúdo arquivístico (ADR 0018) |
/api/v1/system/workers/{worker_name}/schedule |
GET |
autenticado | a agenda do worker, ou nulo quando ele não tem uma (ADR 0015) |
/api/v1/system/workers/{worker_name}/schedule |
PUT |
OPERATE |
cria ou substitui a agenda: o cron de cinco campos, o fuso IANA, o request pinado e o estado |
/api/v1/system/workers/{worker_name}/schedule |
DELETE |
OPERATE |
remove a agenda; as execuções que ela criou ficam no ledger |
/api/v1/system/workers/{worker_name}/settings |
PUT |
OPERATE |
persiste o padrão de engine/preset/batch/options |
/api/v1/system/workers/{worker_name}/settings |
DELETE |
OPERATE |
apaga o override para o worker voltar a seguir o código |
/api/v1/system/workers/{worker_name}/settings/revisions |
GET |
autenticado | quem mudou o quê, quando |
/api/v1/system/runs |
GET |
autenticado | o ledger de execuções, mais recente primeiro; filtros worker, origin, status, fingerprint, trial, limit, offset |
/api/v1/system/runs/{run_id} |
GET |
autenticado | uma execução, com o resultado do ensaio quando ela é uma comparação (ADR 0018) |
/api/v1/system/runs/{run_id}/cancel |
POST |
OPERATE |
pede que uma execução pare: fecha uma na fila que nenhum runner reivindicou, registra o pedido numa em andamento (#72) |
/api/v1/system/failures |
GET |
autenticado | as causas raiz agrupadas; filtros days (padrão 30), worker, limit |
/api/v1/system/health |
GET |
OPERATE |
banco, modelos do Ollama, storage de objetos, as origens da ingestão e o processo |
/api/v1/ingestion/origins |
GET |
autenticado | cada origem com a própria fila, a última execução e a agenda (#44) |
/api/v1/ingestion/origins/{code} |
PATCH |
OPERATE |
o ciclo de vida e a configuração do adaptador (URLs, espera) |
/api/v1/ingestion/origins/{code}/schedule |
PUT |
OPERATE |
o cron e o fuso IANA da origem |
/api/v1/ingestion/origins/{code}/schedule |
DELETE |
OPERATE |
remove a agenda da origem, mantendo a configuração |
/api/v1/ingestion/origins/{code}/runs |
POST |
OPERATE |
enfileira uma execução de ingestão para a origem, com opções só desta execução |
/api/v1/ingestion/manual |
POST |
CURATE |
grava um registro digitado, estrutura o staging e enfileira a transferência |
/api/v1/ingestion/csv/spec |
GET |
autenticado | o mapa do CSV, os tetos dos campos e os limites de linha/bytes (#191) |
/api/v1/ingestion/csv |
POST |
CURATE |
importa uma lista limitada de linhas de CSV, estrutura as aceitas e enfileira uma transferência (#191) |
Só o ADMIN carrega OPERATE; CURATOR e VIEWER leem os workers, o ledger e as falhas, mas não
disparam execução nem mudam padrão. A entrada manual e a importação CSV são as escritas de ingestão
que um curador possui (CURATE): digitar um registro ou importar uma planilha é curadoria, apontar
uma origem para um site ou iniciar uma coleta é operação.
A SPA espelha isso, como cartões de /configuracoes sob Operação:
| Tela | Mostra |
|---|---|
/sistema/workers |
a tela de operação, com duas abas na URL (#207). Painel: os dez workers — engine, preset e modelo, a fila, o que o carimbo de cada um nomeia (com produtor, sem produtor conhecido, sem registro) e um botão Enfileirar execução; acima deles, os cartões dos runners (online, host, versão, concorrência, capacidades, último sinal) e um aviso quando nenhum está online. Um worker que nenhum runner online aceita aparece como sem runner — a linha ainda enfileira e espera. O selo e o fato Agenda mostram o cron, o fuso, a próxima execução e o último período pulado. A configuração efetiva aqui é só leitura. A linha ingestion é a exceção: não tem engine, não tem contadores agregados de pendentes/processados/falhas, e a execução dela pertence a uma origem, então ela leva a /sistema/ingestao em vez de um botão genérico — os números por origem ficam naquela tela. A última execução é um link para o ledger, filtrado por aquele worker e com a linha exata realçada. Execuções: o ledger de execuções e as falhas dos últimos 30 dias agrupadas por causa raiz; clicar numa causa filtra o ledger para as ocorrências dela. Cada linha diz quem pediu (painel, linha de comando ou agenda), a tentativa (tentativa 2/3), a linha que ela retenta e, quando está na fila com not_before, quando começa. Uma linha de ensaio carrega o selo ensaio e leva ao cartão de comparação, para nunca ser confundida com uma execução de produção (ADR 0018). Uma linha ativa oferece Cancelar execução… com confirmação embutida, e um pedido de parada aparece como nota enquanto o worker termina o lote atual. O caminho antigo /sistema/execucoes redireciona para cá com a busca preservada |
/configuracoes/workers |
o padrão persistido de cada worker e suas revisões, com salvar e remover, e a Agenda do worker (o cron, o fuso, o estado habilitado e o request pinado) — a escrita que o painel carregava. Um worker que o catálogo marca trial (hoje a tipologia) também carrega Comparar configurações: duas configurações, o tamanho da amostra, a tabela lado a lado (concordância com o denominador, cobertura, abstenção, a maior gaveta, entropia, segundos por documento, a fila e as horas projetadas) e Usar esta, que salva o corpo medido, e Rodar no acervo inteiro, que lê a fila do corpo salvo e dispara a execução real |
/sistema/ingestao |
Origens, um cartão por fonte configurada: a fila e a última execução, a configuração do adaptador, a agenda, Desativar/Ativar e Enfileirar execução; Adicionar origem abre o seletor das definições sem linha. A seção Entradas mostra as linhas manual/CSV só para leitura, com link para /acervo/nova. O cartão é OPERATE; as rotas de leitura continuam autenticadas (#206) |
/sistema/diagnostico |
banco, modelos do Ollama, storage das miniaturas, as origens da ingestão (ativas/total, com link para /sistema/ingestao) e a configuração efetiva do processo |
Só a aba ativa é montada, então só a leitura dela é consultada em ciclo: o painel a cada 5 s enquanto
uma execução está rodando e a cada 15 s enquanto uma está na fila (o contador do transfer faz uma
chamada a /system/workers custar cerca de dois segundos no acervo real), e o ledger a cada 5 s
enquanto uma entrada está ativa — ociosa, nenhuma das duas consulta. A /configuracoes/workers lê
/system/workers/settings, que resolve a mesma configuração e não conta fila nenhuma — a separação
existe para que uma tela que não mostra números não pague por eles.
Saúde: três endpoints, três perguntas¶
Eles não são intercambiáveis, e juntá-los é o erro a evitar.
| Endpoint | Pergunta | Comportamento |
|---|---|---|
GET /health/live |
o processo está vivo? | não toca em nada, sempre 200 enquanto o processo responde; fora do documento OpenAPI |
GET /health/ready |
esta instância pode receber tráfego? | roda SELECT 1; 200, ou 503 quando o banco não responde; também fora de /api/v1 e fora do contrato |
GET /api/v1/system/health |
qual peça está fora? | o painel humano: contagens do banco, os modelos do Ollama que os presets exigem e quais faltam, o bucket do storage, o resumo de ingestion_origins e a configuração efetiva do processo — segredos como presença, nunca como valor |
O cartão de ingestão lê ingestion_origins, não PUBLIC_SCRAPE_*: desde a #44 a origem é uma linha,
então uma instalação pode ter o pmc configurado com o .env vazio. Ele conta só as origens
configuráveis (#206): as entradas manual/CSV e um código que saiu do registro não são fontes e
não entram no número. O cartão do processo rotula a variável como fallback do adaptador de
referência, e a fila, a agenda e os contadores por origem ficam em /sistema/ingestao.
Uma sonda de liveness que consulta o banco reiniciaria a API sempre que o banco reiniciasse, e é por
isso que live não toca em nada. A sonda de readiness tem engine própria, com timeout de conexão e
de statement, então uma sonda contra um host inalcançável falha rápido em vez de pendurar pelo
timeout de TCP do sistema operacional.
O ledger de execuções¶
archive_worker_runs tem uma linha por execução, tanto da CLI quanto do painel: o ledger é sobre o
worker, não sobre o botão que o iniciou. Uma linha carrega worker_name, status, trigger (CLI,
API ou SCHEDULE), trial (se a execução é uma comparação, ADR 0018), requested_by (e requested_by_user_id), engine_name, preset, o request validado
(o que um runner reproduz), o config resolvido, as options efetivas do worker, attempt, retry_of, not_before, queued_at,
runner_id, started_at, heartbeat_at, lease_expires_at, cancel_requested_at,
cancel_requested_by (e cancel_requested_by_user_id), finished_at, duration_ms, error
e o error_fingerprint gerado.
O config responde "com qual modelo?" e as options respondem "sobre qual texto, com qual limiar?" —
as duas metades de uma execução. As options são resolvidas quando a execução começa, porque o
default de uma lista de colunas vive no dataclass do runner do próprio worker e só ele sabe quais
colunas leu; uma linha enfileirada e ainda não iniciada, ou escrita antes de a coluna existir, carrega
o default vazio e a tela do ledger diz "opções não registradas nesta execução". A identidade do
produtor faz o hash dos mesmos valores no carimbo, então a linha é a metade legível desse hash.
Um ensaio é a exceção que o schema torna explícita: ele não carrega um engine_name/preset/
config único (as configurações estão no request, e os valores resolvidos delas estão em
trial_result.configs), não escreve conteúdo arquivístico, e é a única linha que pode ter um
trial_result. A "última execução" do painel filtra trial = false: um experimento não é o trabalho
que o worker fez por último. Um ensaio ainda ocupa a vaga do worker e aparece como execução ativa
enquanto roda, porque carrega o modelo e lê o acervo.
Os status são QUEUED, RUNNING, SUCCESS, FAILED, INTERRUPTED e CANCELLED. A linha QUEUED é
comitada pela sessão própria do ledger antes de a requisição retornar, para que um runner externo
possa reivindicá-la; a reivindicação é uma instrução (FOR UPDATE SKIP LOCKED) que também grava
runner_id, started_at, heartbeat_at e lease_expires_at. Uma linha QUEUED é o item de
trabalho; a API apenas enfileira.
Um cancelamento é um pedido antes de ser um status: cancel_requested_at (e cancel_requested_by,
com o vínculo _user_id) é gravado enquanto a linha ainda está QUEUED/RUNNING, e só o limite do
lote do worker o transforma em CANCELLED. Cancelar uma execução é a
história inteira.
uq_worker_run_active é um índice único parcial sobre (worker_name) quando o status é QUEUED
ou RUNNING. É ele, e não um lock de processo, a trava de concorrência: no máximo uma execução por
worker em andamento, entre processos e através de um reload. Um segundo envio vira um
IntegrityError que a API transforma em 409 com uma frase ("Já existe uma execução em andamento
para o worker '…'"). A CLI chega ao mesmo erro, mas não tem tratamento para ele, então termina com o
traceback em vez da frase.
Um runner reivindica uma linha QUEUED e renova o lease enquanto roda; --concurrency decide quantas
executa de uma vez, e os workers são limitados por CPU/GPU, então o padrão é um. Ele não é um
agendador: sem retry, sem cron, sem prioridade. A fila é um slot por worker, então uma segunda
execução espera no ledger ou responde 409.
Reprocessar um documento ou um lote¶
Rodar um worker de novo é seguro e incremental: a fila dele é a ausência do carimbo do produtor atual, então uma segunda execução toca só o que ainda está pendente. O que devolve uma unidade à fila:
- o texto mudou — uma edição humana ou um trecho de texto aprovado/editado/desfeito. O
embeddingse re-enfileira sozinho, porque o carimbo dele carrega o MD5 do texto efetivo; os carimbos dependentes do texto (worker_ner_v2,worker_typology_classifier_v2,worker_quality_validator_v1) são invalidados pelo serviço de qualidade de texto. - o produtor mudou — outra engine, outro preset, outro
confidence_threshold, outro conjunto de colunas, uma atualização detorch/transformers/sentence-transformers/spaCy, ou a regraLLM_CHECKde onde o validador de qualidade tira a engine. Tudo isso está dentro da identidade, então as unidades afetadas voltam sozinhas; a pré-visualização acima diz quantas antes de a mudança ser salva. - a origem mudou — o
transferreescreve a descrição quando o hash do conteúdo interpretado muda, e a escrita zera os carimbos de IA, então os enriquecimentos precisam rodar de novo. - o catálogo mudou — reescrever um rótulo de assunto muda o hash do conjunto de rótulos, então as
tags afetadas voltam à fila do
macro-category, e acrescentar, renomear ou aposentar uma tipologia faz o mesmo com as descrições: a identidade da tipologia assina o conjunto de rótulos ativas e o carimbo é o único portão do lado da resposta, então uma descrição que já carrega tipologia volta quando o catálogo se move. Uma execução que então recusa limpa a resposta que encontrou — uma resposta escrita por outro produtor não sobrevive a uma recusa deste, ou subir o piso releria o acervo sem mudar rótulo nenhum. Os motivos do validador de qualidade também dependem do catálogo, e é para esse caso que oforceexiste. force=true—embedding,macro-category,ner,quality-validator,thumbnailetypologyaceitam e ignoram o próprio carimbo; omacro-categorycontinua sem tocar numa tag que já tem categoria, enquanto otypologyrelê uma descrição que já carrega tipologia, porque quem decide é o carimbo e não a resposta. É o caminho de volta de um motivo que um hash não vê — uma regra de sinônimos editada, uma exclusão curada, um prompt — e de uma marca de falha: as unidades que ele processa são carimbadas com o produtor atual, o que limpa oERROR. Nothumbnailele também traz de volta os documentos marcados comothumbnail_failed, que é a saída de um bucket que estava fora durante uma execução: a marca existe para um link morto não ficar em laço, não para tornar a queda permanente. Uma execução forçada avança por cursor, então termina;--limit Nlimita ainda mais.
uv run python -m scrinalia.domains.archive.workers.runner embedding --option force=true
uv run python -m scrinalia.domains.archive.workers.runner typology --option force=true --limit 200
uv run python -m scrinalia.domains.archive.workers.runner quality-validator --option force=true
uv run python -m scrinalia.domains.archive.workers.runner thumbnail --option force=true
A janela de reprocessamento fecha no primeiro registro aprovado por uma pessoa
HUMAN_APPROVED é a proteção: ai_writable_documents() a exclui, e cleaning, ner,
typology, thumbnail e quality-validator filtram por ela — force incluído, e é por isso que
uma execução forçada relê todo documento gravável pela IA, não todo documento. O transfer se
recusa a sobrescrever uma descrição aprovada mesmo quando o hash da origem mudou. As duas exceções
documentadas são o trabalho derivado — o embedding, que re-embeda um texto mudado, e o
macro-category, cuja unidade é a tag e cuja fila é "ainda sem categoria", então a categoria de um
curador nunca é reescrita.
Não há rota nem flag que limpe um carimbo para os workers que não aceitam force (transfer,
cleaning, conflict-judge): para devolver um documento à fila ali, é preciso remover aquela chave do
execution_log dele no banco. Trate isso como reparo, não como operação de rotina.
Execuções INTERRUPTED¶
Uma execução fica RUNNING sob um lease. O runner o renova a cada --heartbeat-seconds; quando o
lease vence — o runner morreu, ou o sinal parou — o próximo runner (ou a próxima CLI, que tem o mesmo
lease) marca a linha como INTERRUPTED, com finished_at e o texto "O runner parou de enviar sinal
antes do fim desta execução." O boot da API não repara nada, então vários processos da API são
seguros. A varredura toca só as linhas vencidas: a reivindicação viva de um irmão nunca é fechada.
Uma execução agendada INTERRUPTED é retentada pelo scheduler (veja Agendamento e retentativas); uma manual ou da CLI não é. Para o caso manual, depois de uma execução INTERRUPTED:
- Leia
GET /api/v1/system/runs?status=INTERRUPTED(ou o painel) e abra o erro da linha. - Corrija a causa — um modelo do Ollama faltando, um bucket inalcançável, um banco que estava fora.
- Dispare o worker de novo. Os carimbos por unidade são o que torna isso seguro: a nova execução lê só o que a interrompida não terminou.
A execução enfileirada sem runner online espera, e o painel diz isso; Cancelar uma execução é a saída. A política de retry é a #43 e está em Agendamento e retentativas.
Cancelar uma execução¶
POST /api/v1/system/runs/{run_id}/cancel (OPERATE) para uma execução (#72). A rota responde dois
desfechos, e a diferença é a que a máquina de estados do ledger desenha:
- uma linha
QUEUEDque nenhum runner reivindicou (runner_id IS NULL) nunca começou e nada está escrevendo, então fechaCANCELLEDna hora e libera o slot do worker; - uma linha em andamento só recebe o pedido:
cancel_requested_atecancel_requested_bysão gravados, o status continuaQUEUED/RUNNING, euq_worker_run_activesegura o slot. O worker consulta o pedido uma vez por lote, comita o lote que já começou, e o runner fecha a linhaCANCELLED— semerror, porque uma parada deliberada não é uma falha que a tela de falhas agrupadas deva apresentar como causa raiz.
O pedido é idempotente: um segundo clique responde o mesmo desfecho e preserva o primeiro autor e o
primeiro instante. Uma execução que já chegou a um estado terminal responde 409, e um id
desconhecido responde 404. A SPA oferece a ação numa linha ativa da aba Execuções de
/sistema/workers, com a confirmação que as outras ações destrutivas usam; um pedido de parada
aparece como nota enquanto a linha ainda está em execução.
O limite honesto é um lote (db_batch_size, 64 por padrão): o ThreadPoolExecutor não mata uma
thread e uma passada do torch não pode ser interrompida, então future.cancel() só cobre o caso na
fila. Uma linha QUEUED é cancelável na hora; uma RUNNING coopera, e o tempo que isso leva é a
duração do lote em que ela está — minutos quando o validador de qualidade chama o LLM por documento.
SIGINT/SIGTERM dão à CLI o mesmo desfecho: o primeiro sinal grava o mesmo pedido (a metade do
processo), o worker para no próximo limite de lote e a execução fecha CANCELLED; um segundo sinal
força a saída. O daemon é deliberadamente diferente: ele para de reivindicar no SIGTERM e deixa as
linhas em voo para o lease, então um runner que está sendo reiniciado reporta INTERRUPTED — um
acidente — e não CANCELLED — uma decisão.
Uma execução CANCELLED nunca é retentada. A checagem é uma função (RunControl.checkpoint) consultada
uma vez por lote por todo worker que itera, e
testing/unit/archive/workers/test_worker_cancellation_gate.py percorre o catálogo e falha quando um
worker que itera deixa de consultá-la.
Agendamento e retentativas¶
O scheduler é um processo próprio (ADR 0015):
python -m scrinalia.domains.archive.workers.scheduler. Ele toma o pg_try_advisory_lock pela vida
toda — uma segunda instância fica em standby — e bate a cada 30 s (--tick-seconds). Cada agenda devida
é processada numa transação com FOR UPDATE SKIP LOCKED: se o worker já tem uma linha
QUEUED/RUNNING, o período é pulado (last_skipped_at é gravado); senão a execução é enfileirada com
trigger=SCHEDULE e attempt=1, e o next_run_at avança para a próxima ocorrência futura. Um
scheduler que ficou fora por vários períodos cria um catch-up, não um replay.
A agenda é uma linha por worker (archive_worker_schedules): um cron de cinco campos
(minuto hora dia-do-mês mês dia-da-semana), um fuso IANA, o estado habilitado e o request pinado. Ela
é escrita em /configuracoes/workers (a seção Agenda) e lida em /sistema/workers; DEFAULT_TIMEZONE
é o fuso da instância e o padrão do formulário. Uma agenda pausada não guarda próxima execução;
religá-la a recalcula do cron. O cron é avaliado no fuso da agenda, nunca no do servidor: o buraco do
horário de verão roda no próximo instante válido, e a hora repetida no dia de recuo roda uma vez.
As origens têm a agenda na própria linha (ingestion_origins.cron/timezone) e ela é escrita em
/sistema/ingestao, não na tela de configuração dos workers. Uma origem devida enfileira uma execução
ingestion com origin_code; a vaga é uma por origem (uq_worker_run_active inclui
COALESCE(origin_code, '')), então a coleta de uma origem não espera atrás da de outra, enquanto os
workers de archive mantêm uma vaga por worker. Um período que chega com aquela origem rodando é pulado
e last_skipped_at é gravado; uma ingestão agendada que falha é retentada pela mesma política de um
worker.
Uma execução agendada que termina FAILED ou INTERRUPTED é retentada pelo scheduler como uma nova
linha do ledger: attempt = anterior + 1, retry_of a linha falha, o mesmo request e um not_before
60 s x 2^(attempt-1) depois, ±20% de jitter, teto de 30 minutos. Três tentativas no total; a terceira
falha é final. Uma execução manual ou da CLI nunca é retentada. A linha do retry nasce na hora e espera
no slot — o claim só pega uma linha cujo not_before passou — então uma execução manual do mesmo worker
responde 409 até ela começar. Uma execução CANCELLED nunca é retentada: só as agendadas FAILED e
INTERRUPTED são (ADR 0014, decisão 9).
Dois avisos que sobrevivem ao scheduler:
- Uma agenda pode disparar com uma execução ativa. O período é pulado, não enfileirado: a fila é um slot por worker, e o painel mostra o último horário pulado. Um período perdido é um catch-up, nunca um replay.
- A concorrência é recusada pelo banco, não por um lock. O índice único parcial rejeita a segunda
execução em andamento do mesmo worker; a API responde
409e a CLI levanta a mesma recusa. Nada no processo serializa as chamadas, então a garantia vale entre processos, através de um reload e mesmo quando a CLI e o painel discordam sobre quem manda. Disparar um worker diferente é aceito mesmo com outro rodando — a linha fica na fila e um runner a reivindica quando tem folga — porque o índice limita a uma execução por worker; o--concurrencydo runner decide quantas ele executa de uma vez.
A rota da API é uma mutação, então também exige sessão de ADMIN autenticada e cabeçalho Origin de
mesma origem. Um cron externo continua uma forma suportada de rodar trabalho pontual, pela CLI ou pela
rota, mas não é mais a resposta embutida: a agenda é dona da próxima execução, do pulo e do catch-up.
Lendo as falhas¶
GET /api/v1/system/failures agrupa os dois ledgers de erro pela mesma chave: a coluna gerada
error_fingerprint, calculada pelo PostgreSQL a partir do texto de erro em archive_worker_runs e
em archive_api_errors. Uma causa raiz que quebrou uma execução de worker e uma requisição HTTP
aparece uma vez, com sources (WORKER, API ou os dois) dizendo onde foi vista.
| Campo | Significado |
|---|---|
fingerprint |
a identidade do grupo e o filtro que leva de volta às ocorrências |
sample |
a ocorrência mais recente, sem normalização — o fingerprint lê como chave, não como frase |
occurrences |
quantas vezes aconteceu na janela |
first_seen, last_seen |
a janela em que aconteceu |
sources |
de qual(is) ledger(s) veio |
worker_names |
os workers que quebrou, quando houver |
last_path, last_request_id |
a rota e a referência da requisição da última ocorrência de API do grupo |
O total da resposta é o número de grupos na janela, não a soma das ocorrências. De propósito
não há total geral entre os grupos: uma execução de worker e uma requisição HTTP são unidades
diferentes, e somá-las produziria um número sobre o qual ninguém age.
Os dois detalhes que tornam a leitura útil:
last_path/last_request_idvêm da última ocorrência de API do grupo, não da última ocorrência de qualquer tipo. Um grupo misto cuja linha mais nova é uma execução de worker ainda tem uma rota e uma referência que vale mostrar.- O filtro
workerrecorta as linhas, não os grupos. Um grupo que também quebrou a API mantém só as ocorrências de worker, que é o sentido de "este worker falhou"; um grupo só de API sai fora.
O last_request_id é a costura com o log: o mesmo valor está no registro de ui_stream.jsonl da
requisição, então um grupo leva ao traceback.
Logs¶
O logging é configurado por dois ajustes:
| Ajuste | Padrão | Significado |
|---|---|---|
LOG_DIR |
logs |
o diretório onde os sinks de arquivo são escritos |
LOG_LEVEL |
INFO |
o nível do sink de console e do stream estruturado |
Três sinks são instalados:
- o terminal, em
LOG_LEVEL; logs/critical.log, emERROR, rotacionado em 10 MB e mantido por 30 dias — o arquivo de post-mortem;logs/ui_stream.jsonl, emLOG_LEVEL, JSON serializado, rotacionado em 50 MB e mantido por 7 dias — o stream estruturado que carrega o id da requisição em todo registro.
Toda requisição sai com um X-Request-ID: o que o cliente mandou (aceito só quando tem no máximo 64
caracteres de [A-Za-z0-9._:-]) ou um gerado. Ele é devolvido no cabeçalho da resposta e amarrado ao
contexto de log, então todo registro escrito enquanto aquela requisição era servida o carrega —
inclusive o que o handler de exceção não tratada escreve. A SPA o mostra como "referência" ao lado de
um erro.
A linha de acesso é escrita pelo mesmo middleware, não pelo uvicorn, porque o registro do uvicorn não tem id de requisição:
↔️ GET /api/v1/system/workers -> 200 em 1842.3 ms
❌ POST /api/v1/system/workers/ner/runs -> 500 em 12.4 ms
Um 5xx é aviso, o resto é linha de informação. As duas sondas do orquestrador (/health/live e
/health/ready) ficam fora da linha de acesso — um orquestrador pergunta a cada poucos segundos e
rotacionaria um arquivo de 50 MB só com "200 OK" — mas não ficam fora da correlação.
Recuperar acesso¶
A recuperação de senha é guiada pelo administrador, e qual porta se aplica é a única pergunta:
- Alguém esqueceu a senha e um administrador consegue entrar. O administrador abre Configurações → Usuários, encontra a conta e usa redefinir senha — um botão na própria linha da conta, então a redefinição é alcançada sem expandir a ficha. A senha digitada ali é temporária: a conta a troca no primeiro acesso. A redefinição também encerra todas as sessões dessa conta e destrava um bloqueio, que é o único caso que a tela sozinha resolve. Ela não tem desfazer.
- Nenhum administrador consegue entrar — o último ativo está bloqueado, desativado ou não existe mais. Aí a operação que resolveria é justamente a que ninguém alcança, e a entrada é o terminal do servidor:
# Quais contas existem e qual está ativa. O endereço é o que os comandos abaixo recebem.
uv run python -m scrinalia.domains.identity.cli list
# Uma senha explícita. Ela é gravada como veio; a conta não é obrigada a trocá-la no primeiro acesso.
uv run python -m scrinalia.domains.identity.cli reset-password --email pessoa@instituicao.org --password 'a nova senha'
# Sem --password, ele gera uma, imprime uma vez e marca a conta como temporária.
uv run python -m scrinalia.domains.identity.cli reset-password --email pessoa@instituicao.org
# Uma conta desativada é reativada antes de conseguir entrar de novo.
uv run python -m scrinalia.domains.identity.cli activate --email pessoa@instituicao.org
O CLI roda no host contra o mesmo banco e chama o mesmo serviço que a API chama, então a política
de senha, a revogação de sessões e a guarda de último administrador por trás de deactivate e
set-role são o mesmo código, e não uma segunda implementação com menos verificações. Ele também é
como a primeira conta de administrador é criada (Instalação e implantação).
Duas coisas deliberadamente não existem, e a tela de entrada diz isso em vez de fingir o contrário: não há redefinição por e-mail (nada configura SMTP, e a ADR 0009 deixa a recuperação de autosserviço fora de escopo), e não há tabela de tokens de redefinição para guardar, expirar ou vazar. O Esqueci minha senha da tela declara esses dois caminhos e não promete mensagem nenhuma.
Medir o acervo¶
Duas tabelas respondem a duas perguntas diferentes, e um número tirado da tabela errada engana.
execution_log diz se um worker passou por uma unidade: é o carimbo de onde a fila é construída.
archive_worker_runs diz o que rodou, quando, com que engine e preset, sobre quais opções, quanto
demorou e como
terminou (SUCCESS, FAILED, INTERRUPTED, CANCELLED). Uma contagem de carimbo vazia é um fato sobre a fila;
a linha no ledger é o fato sobre a execução que explica a fila — uma etapa em zero e uma execução
interrompida na subida são o mesmo evento visto de duas tabelas.
Esta é a consulta com que o acervo de referência foi medido. Rode no banco da própria instalação e trate todo número como uma medição com data, não como uma constante:
docker exec <container> psql -U <user> -d <database> -t -A -F' | ' -c "
SELECT 'descrições', count(*)::text FROM archive_documents
UNION ALL SELECT 'com pai', count(*)::text FROM archive_documents WHERE parent_id IS NOT NULL
UNION ALL SELECT 'sem nível', count(*)::text FROM archive_documents WHERE level_id IS NULL
UNION ALL SELECT 'tags', count(*)::text FROM archive_tags
UNION ALL SELECT 'tags sem categoria', count(*)::text FROM archive_tags WHERE macro_category_id IS NULL
UNION ALL SELECT 'propostas sugeridas', count(*)::text FROM archive_tag_merge_proposals WHERE status='SUGGESTED'
UNION ALL SELECT 'propostas aplicadas', count(*)::text FROM archive_tag_merge_proposals WHERE status='APPLIED'
UNION ALL SELECT 'rungs decididos', count(*)::text FROM archive_hierarchy_node_plans WHERE status <> 'SUGGESTED'
UNION ALL SELECT 'rungs no total', count(*)::text FROM archive_hierarchy_node_plans
UNION ALL SELECT 'ner_v2', count(*)::text FROM archive_documents WHERE execution_log ? 'worker_ner_v2'
UNION ALL SELECT 'typology_v2', count(*)::text FROM archive_documents WHERE execution_log ? 'worker_typology_classifier_v2'
UNION ALL SELECT 'macro_v1 (tags)', count(*)::text FROM archive_tags WHERE execution_log ? 'worker_macro_category_v1'
UNION ALL SELECT 'embedding_v1', count(*)::text FROM archive_documents WHERE execution_log ? 'worker_embedding_v1'
UNION ALL SELECT 'quality_validator_v1', count(*)::text FROM archive_documents WHERE execution_log ? 'worker_quality_validator_v1'
UNION ALL SELECT 'publicados', count(*)::text FROM archive_documents WHERE is_published
UNION ALL SELECT 'revisões humanas', count(*)::text FROM archive_document_revisions;"
Uma armadilha vale saber antes de ler esses números: ? diz que a chave existe, não que ela está
atual. Como os carimbos chaveados por identidade carregam o produtor, um documento carimbado por outro
modelo, por uma dependência mais antiga ou por outro limiar conta aqui e continua pendente para a
configuração que está no código — a coluna pending do painel e a pré-visualização de fila são o que
responde essa pergunta. As contagens cruas servem para "este worker já passou por este acervo".
O site da documentação¶
Estes guias são publicados em https://cassiodalla.github.io/Scrinalia/ — inglês na raiz, português
em /pt/ — e quem publica é a CI; não é uma pessoa. O job docs constrói o site com
mkdocs build --strict sobre o histórico completo (o relatório de freshness é um relatório contra o
git, ADR 0010), envia site/ como artefato do Pages, e o job docs-publish publica exatamente esse
artefato. A decisão, e as alternativas que ela recusou, estão na
ADR 0012.
Três consequências valem antes de mexer num workflow ou no tema:
- Só um push para
mainpublica. Um merge emdevconstrói o site e não publica nada: o site é a documentação da versão, emainé o ramo que se move numa versão. O ambientegithub-pagesconfia emmain— e no ramogh-pagesque a publicação manual usava — então um deploy a partir dedevexigiria mudar essa política antes. - Repetir a execução do workflow é a recuperação. O deploy pega o artefato que a mesma execução
construiu, então uma falha passageira se resolve com "re-run jobs". Não existe passo
mkdocs gh-deployegh-pagesnão é mais a origem do site: publicar à mão deixou de ser um caminho, que é o que impede o site publicado de ser uma versão mais velha que a tag. - O tema é a identidade do sistema, e vive em dois lugares presos um ao outro:
docs/assets/(a marca, o favicon, a PT Serif auto-hospedada com a licença) edocs/assets/stylesheets/scrinalia.css, que carrega os tokens deapps/curator/src/styles.css.testing/unit/docs/test_docs_brand.pyfalha quando falta um arquivo que omkdocs.ymlnomeia — o--strictnão pega isso — ou quando uma cópia se afasta do arquivo original do curador.
Limites conhecidos e trade-offs aceitos¶
São medidos, aceitos e não estão esperando correção. Estão aqui para o operador saber que comportamento esperar, e para um limite já pago não ser lido como defeito.
- Ninguém é avisado. A observabilidade é o painel, o ledger de execuções e os grupos de falha; um serviço externo de alerta está deliberadamente fora do sistema (ADR 0005). Uma causa raiz nova é encontrada por quem abre a tela, não por uma notificação.
- O runner alcança o banco. Um runner em outra máquina precisa de um caminho privado até o
PostgreSQL e de uma credencial; o
DB_*da API pode ficar local (ADR 0014). A role de menor privilégio opcional e o túnel estão no guia de instalação. - O lease não é um fencing token. Um runner que perdeu o lease ainda pode escrever o acervo até o worker parar; os carimbos tornam a re-execução idempotente e a linha do ledger é cercada, mas uma execução duplicada depois de um lease vencido é possível. Um fence rígido é trabalho futuro.
- A fila é um slot por worker. Uma segunda execução de um worker com uma em andamento responde
409, e um agendamento precisa coalescer; backlog ou prioridade não é o que esta fila é. - O retry é limitado e não classificado. Uma falha agendada gasta até três tentativas, qualquer que seja a causa: um modelo faltando custa três tentativas limitadas, porque um classificador errado na outra direção ou desperdiça a GPU ou esconde uma falha real. O retry segura o slot do worker durante o backoff.
- Um scheduler, um lock. O advisory lock é liberado quando a conexão morre e um standby assume dentro do intervalo de retentativa dele; ainda há uma janela de um tick sem scheduler.
- O cancelamento para no limite do lote. Uma execução
QUEUEDque nenhum runner reivindicou fecha na hora; umaRUNNINGmantém o slot até terminar o lote atual, então parar uma execução do validador de qualidade que chama o LLM por documento pode levar minutos. Um kill não é um cancelamento: a morte do processo éINTERRUPTEDquando o lease vence. - O
pathdo arranjo não é garantido pelo banco. A coluna é materializada pelo serviço, então umUPDATEescrito à mão pode divergi-la da árvore; o diagnósticoPATH_DIVERGENCEé o que encontra. Não escrevapathdiretamente — passe pelas rotas que são donas da movimentação. - A autenticação tem limites conscientes. O limitador de tentativas de login é por processo e
zera no restart, e é por isso que a defesa durável é a coluna de bloqueio por conta; revogar uma
sessão registra que ela foi revogada, não quem revogou; e OIDC/SSO, segundo fator e recuperação
de senha por e-mail estão fora de escopo (ADR 0009). A recuperação é o administrador ou o CLI no
host (veja Recuperar acesso). A primeira conta é uma janela. Enquanto a
auth_usersestiver vazia, oPOST /api/v1/setup/adminé público e quem alcançar a instância primeiro pode criar o administrador; o lock de tabela torna duas tentativas simultâneas seguras, e nada torna a janela segura (ADR 0011). Configure a instância antes de expô-la, e leia oGET /api/v1/setup/status—{"needs_setup": true}numa instância acessível é um convite. - O contrato não declara o cookie de sessão. O documento OpenAPI não carrega um esquema
securitypara ele, porque uma exigência global também marcaria as rotas de difusão e as sondas de saúde como protegidas (ADR 0009). Um cliente gerado não consegue descobrir a exigência; ele recebe 401 como qualquer requisição anônima. - A ordenação depende do modo, e essa é uma decisão do arquivista. Uma busca semântica que
encontra mais não é uma busca que ordena melhor: a ordem lexical ganha quando o termo é
exato, a semântica quando o assunto é conhecido mas o vocabulário do fundo não, e o modo híbrido
funde as duas para que a escolha não seja necessária. Os três modos e quando preferir cada um estão
no guia de curadoria, e
testing/evaluation/retrieval_modes.pymede os três por estrato de consulta.