Skip to content

A pausa publica o contrato, o app se registra, e o refine ajusta fora do pipeline - #24

Merged
davidbenal merged 9 commits into
mainfrom
david/PROD-2128-refine-e-registro
Aug 25, 2026
Merged

A pausa publica o contrato, o app se registra, e o refine ajusta fora do pipeline#24
davidbenal merged 9 commits into
mainfrom
david/PROD-2128-refine-e-registro

Conversation

@davidbenal

Copy link
Copy Markdown
Collaborator

Segunda frente da Etapa 2 (PROD-2128), sobre o motor vendorado da #23. Três coisas que faltavam para o Crystal Ball rodar como app de verdade, cada uma no seu commit, CI verde entre um e outro.

1. A pausa publica seu contrato

state["pending_aceita"]/pending_campo_livre" (workflow_runner.py:382-383) já eram persistidos pelo runner, mas nada os publicava: run --json só devolvia step_id/prompt_to_user/options, runs --json não dizia o que uma pausa aceita, e não existia como consultar UMA run pausada por fora do stdout de quem a disparou.

contrato_persistido(state), extraída de _resolver_escolha, é a fonte única lida nos três lugares: o ramo pausado de run --json (ganha aceita/campo_livre), as quatro chaves pending_* novas de runs --json (pending_aceita, pending_campo_livre, pending_prompt, pending_opcoes — contagem, não a lista inteira, porque é endpoint de listagem), e o subcomando novo runs show <run_id> --json (que virou runs grupo + show, sem quebrar runs --json/--paused como comando plano — Click com invoke_without_command=True). Preserva o fallback de pausa gravada antes de pending_aceita existir no state, para nunca publicar null onde uma run de verdade está esperando resposta.

2. O manifesto registrado

templates/apps/crystal-ball.yaml não era citado por nenhum instalador, e Tracker.create_workflow é um INSERT puro contra slug UNIQUE (migrations/001_initial.sql:38) — registrar duas vezes estourava IntegrityError cru, o bug que o David batia ao registrar na mão.

Tracker.upsert_workflow faz INSERT ... ON CONFLICT(slug) DO UPDATE, e workflow save passa a usá-lo. studiolocal workflow install [SLUG] [--force] instala manifestos de templates/apps/: valida schema (WorkflowSpec.from_yaml) e o contrato de cada pausa human_pick — reusando _contrato_da_pausa, a MESMA checagem que o runner aplica em execução, não uma reimplementação — antes de gravar qualquer coisa. YAML já presente no root que diverge do template é preservado, a menos que --force; manifesto quebrado não grava nem arquivo nem linha no banco, e não impede os outros do catálogo de instalarem. install chama o catálogo no fim, sem force e silencioso, só no bootstrap (nunca em _ctx(), para não sobrescrever em silêncio uma edição manual a cada invocação).

3. studiolocal refine

Verbo novo: refine <run_id> --alvo <prompt_video|cena:N> --queixa "<texto>" [--json]. Entra pela conversa, depois que o pacote já existe — por isso o par alvo→função é fechado em Python (FUNCOES_POR_CONVERSA, espelhando FUNCOES_POR_MOTOR) e não no manifesto: o interpolador do runner é cego para índice de lista ({{ steps.pacote.prompts_de_cena.0... }} devolve None em silêncio — medido, decisão já tomada, não é para arrumar aqui).

lib/refinamento.py concentra a lógica. Recusa ANTES de qualquer chamada de rede ou escrita: status fora de done/paused (running ainda grava, failed não tem resultado), cena inexistente (lista os números disponíveis), run sem sessão (generations.session_id é NOT NULL). Escreve sempre no lugar canônico que as ui.vistas do manifesto leem (step_outputs.pacote.resultado...); rechama validar_prompt_video depois de refinar o prompt de vídeo, porque a função não revalida sozinha; marca nota_desatualizada e nunca recalcula a nota (_criticar é privada e cara) nem toca status/finished_at. Histórico append-only em state["refinamentos"], com antes para permitir desfazer manualmente.

Tracker.update_run_state_if_status faz a escrita atômica (UPDATE ... WHERE status = ?): uma mudança de status entre a leitura e a escrita não é sobrescrita em silêncio — a generation já criada fica registrada, e o comando sai com exit 2. marcar_custo_nao_apurado, extraída de workflow_runner.py:567-578, é a fonte única do cost_source='nao-apurado', chamada tanto pelo runner quanto pelo refine.

4. Teste que toca o motor vendorado de verdade

Todo teste do Crystal Ball até aqui troca o ReasonRouter por um duplo — nenhum carregava lib/motores/crystalball_llm.py de fato. O motor só importa stdlib mais requests, então carregá-lo em teste é offline e barato.

tests/test_motor_vendorado.py confere: o blob do arquivo bate com o hash declarado em PROVENIENCIA.md (mesmo método do git hash-object); carga.motor() registra _crystalball_motor em sys.modules e não toca sys.path; a assinatura real de cada função em FUNCOES_POR_MOTOR e FUNCOES_POR_CONVERSA aceita os argumentos que reason_engines.py/refinamento.py de fato passam; PESOS tem 7 chaves, soma 1.0 e é idêntico ao PESOS_REAIS copiado à mão em test_reason_engines.py; chave_ligada() restaura o ambiente mesmo quando o bloco levanta. README ganha os três verbos novos na seção de comandos.

Prova

pytest -q                                   319 passed   (280 de base + 39 novos)
ruff check . --select E9,F --output-format concise    All checks passed!

Desvios do plano

  • Nenhuma assinatura real diverge do que o plano descrevia (regenerate_scene(contexto, cena), refine_video_prompt(prompt, complaint, biblia=None), Tracker.update_run(...) incondicional existente): tudo bateu com o código real.
  • runs virou um click.Group (invoke_without_command=True) em vez de continuar click.Command, para caber runs show como subcomando sem quebrar runs --project/--paused/--json como estava. Não havia outra forma de encaixar os dois no Click sem isso.
  • workflow install some da leitura de _apps_dir() (nova função) em vez de inline, para o teste do manifesto quebrado poder apontar para um diretório temporário via monkeypatch sem depender do conteúdo real de templates/apps/.

🤖 Generated with Claude Code

davidbenal and others added 9 commits August 24, 2026 17:09
`state["pending_aceita"]`/`pending_campo_livre` já eram persistidos pelo
runner, mas nada os publicava: `run --json` só devolvia step_id/prompt/options,
`runs --json` não dizia o que uma pausa aceita, e não existia como consultar
UMA run pausada por fora do stdout de quem a disparou.

`contrato_persistido(state)`, extraída de `_resolver_escolha`, é a fonte única
agora lida nos três lugares: o ramo pausado de `run --json`, as quatro chaves
`pending_*` novas de `runs --json`, e o subcomando `runs show <run_id> --json`
(que virou `runs` grupo + `show`, sem quebrar `runs --json/--paused` como
comando plano). Preserva o fallback de pausa gravada antes de `pending_aceita`
existir no state, para não publicar `null` onde uma run de verdade está
esperando resposta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`templates/apps/crystal-ball.yaml` não era citado por nenhum instalador, e
`Tracker.create_workflow` é um INSERT puro contra `slug UNIQUE`
(migrations/001_initial.sql:38) — registrar duas vezes estourava
`IntegrityError` cru, o bug que o David batia ao registrar na mão.

`Tracker.upsert_workflow` faz `INSERT ... ON CONFLICT(slug) DO UPDATE`, e
`workflow save` passa a usá-lo. `studiolocal workflow install [SLUG] [--force]`
instala manifestos de `templates/apps/`: valida schema e o contrato de cada
pausa `human_pick` (reusando `_contrato_da_pausa`, a mesma checagem que o
runner faz em execução) antes de gravar qualquer coisa; YAML já presente no
root que diverge do template é preservado, a menos que `--force`; manifesto
quebrado não grava nem arquivo nem linha no banco, e não impede os outros do
catálogo de instalarem. `install` chama o catálogo no fim, sem force e
silencioso, só no bootstrap (nunca em `_ctx()`), para uma máquina nova nascer
com o app já registrado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Verbo novo, `refine <run_id> --alvo <prompt_video|cena:N> --queixa "<texto>"
[--json]`. Entra pela conversa, depois que o pacote do Crystal Ball já existe
— por isso o par alvo->função é fechado em Python (`FUNCOES_POR_CONVERSA`,
`reason_engines.py`) e não no manifesto: o interpolador do runner é cego para
índice de lista, e refinamento não pode ser alcançado por passo declarado.

`lib/refinamento.py` concentra a lógica. Recusa ANTES de qualquer chamada de
rede ou escrita: status fora de done/paused, cena inexistente (lista os
números disponíveis), run sem sessão (generations.session_id é NOT NULL).
Escreve sempre no lugar canônico que as `ui.vistas` leem
(`step_outputs.pacote.resultado...`), rechama `validar_prompt_video` depois de
refinar o prompt de vídeo (a função não revalida sozinha), marca
`nota_desatualizada` e nunca recalcula a nota nem toca `status`/`finished_at`.
Histórico append-only em `state["refinamentos"]`.

`Tracker.update_run_state_if_status` faz a escrita atômica (`UPDATE ... WHERE
status = ?`): uma mudança de status entre a leitura e a escrita não é
sobrescrita em silêncio — a generation já criada fica, e o comando sai com
exit 2. `marcar_custo_nao_apurado`, extraída de `workflow_runner.py`, é a
fonte única do `cost_source='nao-apurado'`, usada tanto pelo runner quanto
pelo refine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Todo teste do Crystal Ball até aqui troca o ReasonRouter por um duplo — nenhum
carregava lib/motores/crystalball_llm.py de fato. O motor só importa stdlib
mais requests, então carregá-lo em teste é offline e barato.

tests/test_motor_vendorado.py confere: o blob do arquivo bate com o hash
declarado em PROVENIENCIA.md (mesmo método do `git hash-object`); carga.motor()
registra `_crystalball_motor` em sys.modules e não toca sys.path; a assinatura
real de cada função em FUNCOES_POR_MOTOR e FUNCOES_POR_CONVERSA aceita os
argumentos que reason_engines.py e refinamento.py de fato passam; PESOS tem 7
chaves, soma 1.0 e é idêntico ao PESOS_REAIS copiado à mão em
test_reason_engines.py; chave_ligada() restaura o ambiente mesmo quando o
bloco levanta.

README.md ganha os três verbos novos (workflow install, runs show, refine) na
seção de comandos do terminal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ó o status

update_run_state_if_status fazia UPDATE ... WHERE status = ?. Como refine
nunca muda status, duas chamadas concorrentes sobre a MESMA run com o MESMO
status (ex: A refina prompt_video, B refina cena:1, ao mesmo tempo) passavam
as duas pela guarda, e a segunda escrita sobrescrevia a primeira por
completo, em silêncio, sem levantar RefinamentoConcorrente. Reproduzido com
duas conexões SQLite lendo o mesmo state antes de qualquer escrita.

Tracker.update_run_state_if_status ganha state_bruto_esperado opcional, que
estende o WHERE para comparar também a string exata do state lido no início
de refinar(). Qualquer escrita concorrente no meio, com status mudando ou
não, agora zera o rowcount e refinar() levanta RefinamentoConcorrente como
já fazia para o caso de status.

Teste novo reproduz o cenário com duas leituras do mesmo (status, state) e
confirma que a segunda escrita falha e o state final é o da primeira,
intacto. Confirmado que falha sem o conserto antes deste commit.
resumo devolvido por refinar() (e por `refine --json`) só trazia run_id,
alvo, funcao, generation_id e o_que_mudou — quem chamasse não tinha como
saber o que de fato foi escrito sem uma segunda leitura para comparar.

Acrescenta status (da run, inalterado pelo refine), resultado (o valor
novo escrito: o prompt de vídeo inteiro para prompt_video, a ficha da cena
para cena:N), avisos (revalidado para prompt_video; None para cena:N, que
não recalcula _avisos) e nota_desatualizada (sempre true).
… e runs --json

3a) No ramo `except WorkflowPaused` de `run_cmd`, `_resumo(p.run_id)` e
`_contrato_da_run(tracker, p.run_id)` liam e faziam parse do MESMO state em
duas consultas separadas. `_carregar_resumo` agora devolve (resumo, estado)
numa consulta só, e `_contrato_da_run` ganha um parâmetro opcional `estado`
para reusar o que já foi carregado — quem só tem o run_id continua
funcionando exatamente como antes.

3b) Em `_runs_listar` (`runs --json`), `_pendente(row)` e `_pendencia_json(row)`
eram chamadas juntas por linha, cada uma com seu próprio json.loads do mesmo
state. `_pendencia_json` passa a fazer o parse uma vez e devolver também
`pending_step`; `_pendente` continua existindo à parte, sem mudança, para o
branch tabular (não-JSON), que não chama `_pendencia_json`.

Refactor puro: suíte inteira roda sem diferença de comportamento.
Achado real, rodando de verdade: refine --alvo cena:N devolvia sucesso e
trocava o hash da cena no state, mas a descrição continuava a mesma —
nenhum traço da queixa no resultado.

Causa: regenerate_scene (vendorado) não tem parâmetro de queixa no
contrato original, e o wrapper montava `contexto` só com briefingText,
direcao e biblia_visual. A queixa ficava presa em state["refinamentos"],
nunca chegava ao Gemini.

Conserto na camada de wrapper (lib/refinamento.py), sem tocar o motor
vendorado: briefingText é o único campo do contexto que o motor de fato
copia para o prompt que monta a chamada (ctx["briefing_resumo"], em
regenerate_scene, crystalball_llm.py ~1318 — confirmado lendo _call, que
concatena system_prompt + user_text e manda pro Gemini de verdade). O
briefing original é preservado; a queixa entra como um acréscimo
explícito, rotulado como ajuste específico para a cena.

Teste em tests/test_refine.py com MotorFalso captura o contexto recebido
por regenerate_scene e prova que a queixa aparece em briefingText junto
com o briefing original — falha sem a correção (confirmado revertendo o
fix e rodando o teste antes de comitar).
Achado real, conferido direto via sqlite: depois de um refine,
state["nota_desatualizada"] fica true no banco, mas `runs show <id>
--json` tinha uma lista fixa de chaves que não incluía o campo — a tela
que já lê essa chave para acender o aviso de nota possivelmente
desatualizada nunca via o dado, mesmo presente.

Acrescenta `nota_desatualizada` (estado.get("nota_desatualizada", False))
em runs_show. Estendido também para os outros dois lugares que montam
JSON a partir do mesmo state parseado, sem duplicar lógica:

- `_carregar_resumo` (run --json), que alimenta tanto o retorno de
  sucesso quanto o ramo pausado (WorkflowPaused) — uma run pode ter sido
  refinada enquanto pausada (refine aceita status 'paused') e depois
  retomada até uma nova pausa ou até done, então o campo é legítimo nos
  dois ramos, não só no de sucesso.
- `_pendencia_json` (runs --json, listagem): é booleano, vem do mesmo
  parse que os cinco campos `pending_*` já expostos por linha, e não
  reintroduz o custo que o comentário da função rejeita (a lista de
  opções inteira por run).

Teste em tests/test_refine.py roda a CLI de verdade (CliRunner sobre
lib.cli.main), porque o bug estava na função que monta o JSON, não em
refinar() — o resumo devolvido por refinar() já trazia o campo certo
antes desta correção. Falha sem o fix (confirmado revertendo e rodando
antes de comitar).
@davidbenal
davidbenal merged commit dc8ab9f into main Aug 25, 2026
1 check passed
@davidbenal
davidbenal deleted the david/PROD-2128-refine-e-registro branch August 25, 2026 00:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant