Skip to content

O Crystal Ball roda como app declarado, e o manifesto nomeia o motor sem carregá-lo - #22

Closed
davidbenal wants to merge 1 commit into
david/PROD-2127-passo-reason-e-retomadafrom
david/PROD-2128-motor-crystalball
Closed

O Crystal Ball roda como app declarado, e o manifesto nomeia o motor sem carregá-lo#22
davidbenal wants to merge 1 commit into
david/PROD-2127-passo-reason-e-retomadafrom
david/PROD-2128-motor-crystalball

Conversation

@davidbenal

Copy link
Copy Markdown
Collaborator

Primeira frente da Etapa 2 (PROD-2128). Empilhada em david/PROD-2127-passo-reason-e-retomada, não na main, porque a PR #16 da Etapa 1 ainda não mergeou (ADR-024).

O que muda

_run_reason_step recusava duro qualquer motor != "template", e os outputs de um passo reason eram template do próprio manifesto, sem caminho por onde o retorno de uma função entrasse. Este PR abre esse seam, e o fecha com guardas medidas.

O motor é vendorado: cópia byte-idêntica de sultowskigus/mk-crystalball em 13a55d5, blob 22fbdd01, ver lib/motores/PROVENIENCIA.md. A alternativa era um checkout apontado por variável de ambiente, que é escopo de máquina: não viaja pelo git, a outra estação precisaria do próprio clone no ref certo, e o CI deste repo não alcança repositório fora da org.

Duas correções que só apareceram por execução

  • API_KEY e MODEL da linha 37 do motor são código morto no caminho de chamada. Quem lê a chave é _load_config(), chamado fresco dentro de _call (:881). Definir a variável antes do import não faz nada, e reatribuir mod.API_KEY também não. A chave entra por chave_ligada(), um contexto que a põe no ambiente pelo tempo da chamada e a tira depois.
  • webapp/store.py inteiro arrastaria psycopg e um init_store() que escreve crystalball.db ao lado do fonte, por três tuplas de 17 strings. As tuplas entram inlinadas, o módulo sintético vai direto para sys.modules, e sys.path não é tocado. Medido: nem flask, nem cv2, nem numpy, nem psycopg, nem banco criado.

As quatro guardas, e por que cada uma existe

Cada uma corresponde a um comportamento do motor que falha em silêncio, medido em 13a55d5:

Comportamento Onde Guarda
pesquisar_referencias devolve None em falha, e caminhos_criativos refaz a pesquisa quando dossie is None :1153-1163, :1181 None vira {}, que é falsy sem ser None. A run deixa de pagar a chamada mais lenta duas vezes
Os 5 caminhos saem com numero, sem id, e a retomada casa por id :558+, workflow_runner.py:212 id injetado no adaptador. Não dava para consertar no manifesto: _interpolate não itera lista
diagnose tem if dict / elif str não vazia sem else :1207-1212 None, "", lista e int são recusados antes de gastar chamada
recalcular_nota renormaliza pelo peso usado: sem gancho_3s, que é 25%, a nota sai igual :57-68 O adaptador conta os critérios que voltaram e devolve criterios_faltando e peso_faltando. Não declara régua, o que o contrato proíbe

aceita: [item_da_lista, texto_livre]

Era a peça que o contrato da Etapa 1 deixou explicitamente para esta etapa, e não podia ficar em ui: muda o tipo do valor que o motor recebe. Vive no corpo do passo, com campo_livre nomeando a chave, e é persistida junto das opções, para que editar o YAML depois não mude o que uma pausa já feita aceita. Texto em branco é recusado. Run pausada antes desta versão continua retomável, e há teste para isso.

Três decisões do David, 2026-08-23

  1. O passo 3 segue o código: uma direcao_afiada, mais o campo livre. As "3 direções" da tabela da jornada eram intenção de produto, não descrição do que existe, e ficam fora.
  2. A run termina done. O último passo não é uma pausa: com o termo de runs.status='paused' no deriveColumn, pausa terminal poria toda sessão concluída em "precisa de você" para sempre. O que o contrato manda não fechar é a sessão, e isso continua verdade: session close segue inalcançável de um passo, o card fica no kanban, a conversa fica aberta. Isto corrige uma frase do contrato-do-app.md.
  3. Teto de duração 45s. É a única guarda contra o truncamento duro de max_tokens=16384 em _gerar_conceito (:1236), onde um pacote de 15 cenas já ocupa o teto de saída do modelo. A 1,5-3s por corte, 15 cenas caem entre 22 e 45 segundos.

Prova

pytest -q                                   274 passed   (194 de base, 80 novos)
ruff check . --select E9,F                  All checks passed!
ruff check . --statistics --exit-zero       23 errors    (o mesmo de antes)

O per-file-ignores do vendorado lista os 30 códigos medidos no arquivo, um a um, em vez de silenciar por atacado, e mantém E9 e F, que é o gate de verdade. Reformatá-lo quebraria a conferência de proveniência por blob sem mudar comportamento nenhum.

tests/test_manifesto_crystal_ball.py é o teste que segura o contrato: o templates/apps/crystal-ball.yaml real, pelo runner real, com o motor substituído por um duplo que registra cada args recebido. Prova a cadeia de estado inteira, as duas pausas nos dois ramos, e que duracao chega como número em vez de string.

O que este PR não faz

  • Nenhuma rodada com chave real. Custa dinheiro e é decisão do operador. O gabarito de forma é barato e repetível; a rodada com o modelo é a próxima prova, e ela mede a latência que a issue #35 precisa.
  • Custo segue nao-apurado. O motor não lê usageMetadata (é regressão do motor JS antigo, que tinha a contagem) e models.yaml não tem uma entrada de texto nem campo de preço por milhão de token. Fica issue.
  • Lado da tela, disparo assíncrono e o termo de runs no deriveColumn são as frentes seguintes, no mk-ai-studio-app.

…sem carregá-lo

A Etapa 1 fixou a fronteira: o manifesto NOMEIA `motor` e `funcao` de uma lista
fechada, e o adaptador resolve. Quem resolvia era nada: `_run_reason_step`
recusava duro qualquer `motor != "template"`, e os `outputs` de um passo `reason`
eram template do próprio manifesto, sem caminho por onde o retorno de uma função
entrasse. Este commit abre esse seam e o fecha com guardas medidas.

O motor é VENDORADO, cópia byte-idêntica de `sultowskigus/mk-crystalball` em
`13a55d5` (blob `22fbdd01`, ver `lib/motores/PROVENIENCIA.md`). Contra um checkout
apontado por variável de ambiente, que é escopo de máquina: não viaja pelo git, a
outra estação precisaria do próprio clone no ref certo, e o CI deste repo não
alcança repositório fora da org. A cópia paga um arquivo com dois donos para que o
CI consiga testar o adaptador.

Carregar o motor sem arrastar a casca custa duas peças, e as duas foram provadas
por execução, não por leitura:

- `webapp/store.py` inteiro arrastaria psycopg, a taxonomia do blueprint e um
  `init_store()` que escreve `crystalball.db` ao lado do fonte, tudo por três
  tuplas de 17 strings que o motor lê em `:182`. As tuplas entram inlinadas em
  `carga.py`, e o módulo sintético é registrado direto em `sys.modules`, sem
  tocar `sys.path`. Medido: nem flask, nem cv2, nem numpy, nem psycopg, nem
  `crystalball.db`.
- `API_KEY` e `MODEL` da linha 37 do motor são CÓDIGO MORTO no caminho de
  chamada: quem lê a chave é `_load_config()`, chamado fresco dentro de `_call`
  (`:881`). Definir a variável antes do import não faz nada, e reatribuir
  `mod.API_KEY` também não. A chave entra por `chave_ligada()`, um contexto, que
  a põe no ambiente pelo tempo da chamada e a tira depois, inclusive em exceção.

O adaptador existe porque quatro comportamentos do motor falham em SILÊNCIO:

1. `pesquisar_referencias` devolve `None` em falha e em dossiê vazio, e
   `caminhos_criativos` refaz a pesquisa quando recebe `dossie is None` (`:1181`).
   A pesquisa é a chamada mais lenta do sistema. `None` vira `{}`, que é falsy
   sem ser None, e a run deixa de pagar duas vezes.
2. Os cinco caminhos saem com `numero`, sem `id`, e a retomada casa por `id`. Sem
   injetar `id`, toda escolha do humano é recusada, longe da causa. E não dava
   para consertar no manifesto: `_interpolate` não itera lista.
3. `diagnose` tem `if dict` / `elif str não vazia` sem `else` (`:1207-1212`).
   Para None, "", lista ou int, o diagnóstico sai bonito, sem o caminho que a
   pessoa escolheu, que é justo o que o interpolador devolve quando um caminho
   não resolve. Os dois defeitos se somavam.
4. `recalcular_nota` renormaliza pelo peso usado (`:57-68`): sem `gancho_3s`, que
   é 25%, a nota sai igual e nada acusa. O adaptador não pode consertar isso sem
   declarar régua, o que o contrato proíbe, então ele CONTA os critérios que
   voltaram e devolve o que faltou.

`aceita: [item_da_lista, texto_livre]` era a peça que o contrato da Etapa 1
deixou explicitamente para cá, e ela não podia ficar em `ui`: muda o TIPO do
valor que o motor recebe. Vive no corpo do passo, com `campo_livre` nomeando a
chave, e é persistida junto das opções, para que editar o YAML depois não mude o
que uma pausa já feita aceita. Texto em branco é recusado; run pausada antes
desta versão continua retomável.

Três decisões do David nesta rodada, todas registradas no cabeçalho do manifesto:
o passo 3 segue o código, com UMA direção afiada mais o campo livre; a run termina
`done`, porque pausa terminal com o termo de `runs.status='paused'` no
deriveColumn poria toda sessão concluída em "precisa de você" para sempre (o que
o contrato manda não fechar é a SESSÃO, e o card e a conversa seguem abertos); e
o teto de duração é 45s, que é a única guarda contra o truncamento duro de
`max_tokens=16384` em `_gerar_conceito`, onde um pacote de 15 cenas já ocupa o
teto de saída do modelo.

274 testes passam, 194 de base e 80 novos, e o gate `E9,F` do CI segue limpo
inclusive sobre o vendorado. O relatório informativo do ruff volta aos 23 achados
herdados por `per-file-ignores` com os 30 códigos medidos no arquivo copiado:
reformatá-lo quebraria a conferência de proveniência por blob sem mudar
comportamento nenhum.

O que este commit NÃO faz, e está registrado: nenhuma rodada com chave real, que
custa dinheiro e é decisão do operador; o custo segue `nao-apurado` porque o motor
não lê `usageMetadata` e `models.yaml` não tem preço por milhão de token; e o lado
da tela, o disparo assíncrono e o termo de `runs` no deriveColumn são as frentes
seguintes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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