From 194b49b3015f5c8a985af5f05817d67964143365 Mon Sep 17 00:00:00 2001 From: Pedro Garcia Date: Fri, 13 Nov 2026 20:37:44 -0300 Subject: [PATCH] feat(codegen): publica o resultado e a variante na trilha T-302 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decisão, sugestão, encerramento da busca e geração apontam o resultado sobre o qual agiram; o evento_id não muda, para a reentrega continuar deduplicada - a conclusão diz o modo da geração, o motivo da sugestão e o desfecho e as candidatas da busca, com a mesma contagem que vai à métrica - campo ausente fica fora do payload: um null explícito seria recusado pelo contrato - a origem da derivação desempata pelo resultado mais recente do código, como a api liga a simulação Review: auto --- codegen/app/contratos/mensagens.py | 9 +++ codegen/app/graph/nodes/decision.py | 1 + codegen/app/graph/nodes/derive_code.py | 4 +- codegen/app/graph/nodes/extract_code.py | 35 ++++++--- .../app/graph/nodes/search_sales_target.py | 32 +++++--- codegen/app/graph/nodes/suggest_adaptation.py | 7 +- codegen/app/repositorio/artefatos.py | 12 ++- .../app/graph/nodes/test_artifact_nodes.py | 58 ++++++++++++++- .../tests/app/graph/nodes/test_decision.py | 17 +++++ .../graph/nodes/test_suggest_adaptation.py | 40 +++++++++- codegen/tests/app/graph/test_busca_meta.py | 74 ++++++++++++++++++- .../app/graph/test_correcao_do_codigo.py | 74 +++++++++++++++++-- .../app/graph/test_derivacao_do_codigo.py | 61 +++++++++++++-- .../app/graph/test_iteracao_da_taxa_nova.py | 70 ++++++++++++++++-- .../repositorio/test_derivacao_postgres.py | 22 +++++- .../tests/app/test_busca_meta_integracao.py | 11 +++ codegen/tests/app/test_mensageria.py | 43 ++++++++--- codegen/tests/app/test_rabbitmq_integracao.py | 9 ++- 18 files changed, 515 insertions(+), 64 deletions(-) diff --git a/codegen/app/contratos/mensagens.py b/codegen/app/contratos/mensagens.py index a56c4105..88c13364 100644 --- a/codegen/app/contratos/mensagens.py +++ b/codegen/app/contratos/mensagens.py @@ -54,6 +54,10 @@ class Veredito(StrEnum): "explicacao", ] +ModoGeracao = Literal["gerado", "derivado", "correcao"] +MotivoSugestao = Literal["proposta", "sem_margem", "incoerente", "nao_suportada", "limite"] +DesfechoBusca = Literal["encontrada", "nao_monotonica", "intervalo_esgotado", "falha_execucao"] + TextoNaoVazio = Annotated[str, Field(min_length=1)] ReferenciaDeElemento = Annotated[ str, @@ -75,6 +79,10 @@ class ConclusaoDaTrilha(ModeloContrato): elementos_implementados: list[ReferenciaDeElemento] | None = None diagnostico: str | None = None concentracao: list[str] | None = None + modo_geracao: ModoGeracao | None = None + motivo_sugestao: MotivoSugestao | None = None + desfecho_busca: DesfechoBusca | None = None + candidatas_simuladas: int | None = Field(default=None, ge=0) encaminhamento: str | None = None @@ -212,6 +220,7 @@ class NoConcluido(ModeloContrato): conclusao: ConclusaoDaTrilha regra_id: UUID | None = None simulacao_id: UUID | None = None + resultado_id: UUID | None = None prompt_id: UUID | None = None codigo_gerado_id: UUID | None = None explicacao_id: UUID | None = None diff --git a/codegen/app/graph/nodes/decision.py b/codegen/app/graph/nodes/decision.py index fd058a31..46fef571 100644 --- a/codegen/app/graph/nodes/decision.py +++ b/codegen/app/graph/nodes/decision.py @@ -154,6 +154,7 @@ async def decision(state: AgentState, config: RunnableConfig) -> AgentState: encaminhamento=encaminhamento, ), regra_id=UUID(state["regra_id"]), + resultado_id=UUID(resultado_id), ) ) if ( diff --git a/codegen/app/graph/nodes/derive_code.py b/codegen/app/graph/nodes/derive_code.py index 74b70db2..022d0972 100644 --- a/codegen/app/graph/nodes/derive_code.py +++ b/codegen/app/graph/nodes/derive_code.py @@ -76,8 +76,10 @@ async def derive_code(state: AgentState, config: RunnableConfig) -> AgentState: job_id=job_id, no=NO_GERACAO_CODIGO, concluido_em=datetime.now(UTC), - conclusao=ConclusaoDaTrilha(resumo=RESUMO_DA_TRILHA), + conclusao=ConclusaoDaTrilha(resumo=RESUMO_DA_TRILHA, modo_geracao="derivado"), regra_id=regra_id, + # O resultado de sucesso da versão de origem, de onde o código derivou. + resultado_id=origem.resultado_id, prompt_id=origem.prompt_id, codigo_gerado_id=codigo_gerado_id, ) diff --git a/codegen/app/graph/nodes/extract_code.py b/codegen/app/graph/nodes/extract_code.py index a01a8b0d..f47df8dc 100644 --- a/codegen/app/graph/nodes/extract_code.py +++ b/codegen/app/graph/nodes/extract_code.py @@ -12,6 +12,7 @@ from app.codigo_gerado import CodigoInvalidoError, extrair_codigo from app.contratos.mensagens import ConclusaoDaTrilha, NoConcluido from app.core.logger import get_logger +from app.correcao_codigo import ENCAMINHAMENTO_CORRECAO from app.graph.core.state import AgentState from app.graph.nodes.code_generation import NO_GERACAO_CODIGO from app.repositorio.artefatos import gravar_codigo, id_do_evento_de_trilha @@ -24,6 +25,10 @@ "Código Python gerado para a regra confirmada e validado: um único bloco, sintaxe " "válida e a função aplicar_regra com a assinatura do contrato." ) +RESUMO_DA_CORRECAO = ( + "Código Python corrigido a partir do código que falhou no worker e validado: um único " + "bloco, sintaxe válida e a função aplicar_regra com a assinatura do contrato." +) async def extract_code(state: AgentState, config: RunnableConfig) -> AgentState: @@ -52,17 +57,25 @@ async def extract_code(state: AgentState, config: RunnableConfig) -> AgentState: # do worker. Se o resultado chegasse primeiro, ela não teria o que amarrar e o cliente # nunca receberia o evento SSE `resultado`. producers = config["configurable"]["producers"] - await producers.no_concluido( - NoConcluido( - evento_id=id_do_evento_de_trilha(job_id, NO_GERACAO_CODIGO, codigo_gerado_id), - job_id=job_id, - no=NO_GERACAO_CODIGO, - concluido_em=datetime.now(UTC), - conclusao=ConclusaoDaTrilha(resumo=RESUMO_DA_TRILHA), - regra_id=regra_id, - prompt_id=prompt_id, - codigo_gerado_id=codigo_gerado_id, - ) + # O mesmo teste de `code_generation`: o prompt de correção é o que gerou esta resposta. + corrigido = state.get("encaminhamento") == ENCAMINHAMENTO_CORRECAO + trilha = NoConcluido( + evento_id=id_do_evento_de_trilha(job_id, NO_GERACAO_CODIGO, codigo_gerado_id), + job_id=job_id, + no=NO_GERACAO_CODIGO, + concluido_em=datetime.now(UTC), + conclusao=ConclusaoDaTrilha( + resumo=RESUMO_DA_CORRECAO if corrigido else RESUMO_DA_TRILHA, + modo_geracao="correcao" if corrigido else "gerado", + ), + regra_id=regra_id, + prompt_id=prompt_id, + codigo_gerado_id=codigo_gerado_id, ) + if corrigido: + # O resultado `erro_codigo` de que o código corrigido partiu. Atribuído só aqui: um + # `resultado_id=None` explícito sairia como `null` e o contrato o recusa. + trilha.resultado_id = UUID(state["resultado_id"]) + await producers.no_concluido(trilha) logger.info("node conclusion published", extra={"no": NO_GERACAO_CODIGO}) return {"codigo_fonte": fonte, "codigo_gerado_id": str(codigo_gerado_id)} diff --git a/codegen/app/graph/nodes/search_sales_target.py b/codegen/app/graph/nodes/search_sales_target.py index 034cd5d2..97aef453 100644 --- a/codegen/app/graph/nodes/search_sales_target.py +++ b/codegen/app/graph/nodes/search_sales_target.py @@ -10,6 +10,7 @@ from app.busca_meta import Ponto, escolher from app.contratos.mensagens import ( ConclusaoDaTrilha, + DesfechoBusca, MetaVendaSugerida, NoConcluido, SemSolucaoMeta, @@ -138,6 +139,7 @@ async def _publicar(state: AgentState, config: RunnableConfig) -> AgentState: return {"busca_meta": busca} job_id, regra_id = UUID(state["job_id"]), UUID(state["regra_id"]) evento = MetaVendaSugerida(job_id=job_id, regra_id=regra_id) + motivo: DesfechoBusca if busca["vencedora"] is not None: vencedora = next(p for p in busca["pontos"] if p["resultado_id"] == busca["vencedora"]) evento.meta_venda = Decimal(vencedora["meta"]) @@ -152,20 +154,28 @@ async def _publicar(state: AgentState, config: RunnableConfig) -> AgentState: "intervalo_esgotado": "Busca encerrada sem meta maior no intervalo pesquisado.", "falha_execucao": "Busca encerrada: uma candidata não produziu resultado válido.", }[motivo] + # A candidata que falhou não entra em `pontos`, mas foi simulada: a mesma conta alimenta a + # métrica e a trilha. + candidatas = len(busca["pontos"]) - 1 + (motivo == "falha_execucao") + trilha = NoConcluido( + evento_id=uuid5(NAMESPACE_URL, f"synapse:{job_id}:{regra_id}:busca_meta"), + job_id=job_id, + regra_id=regra_id, + no="decisao", + concluido_em=datetime.now(UTC), + conclusao=ConclusaoDaTrilha( + resumo=resumo, desfecho_busca=motivo, candidatas_simuladas=candidatas + ), + ) + if evento.resultado_id is not None: + # A vencedora. Sem solução a linha não aponta resultado, e um `resultado_id=None` + # explícito sairia como `null`, que o contrato recusa. + trilha.resultado_id = evento.resultado_id producers = config["configurable"]["producers"] await producers.meta_venda_sugerida(evento) - await producers.no_concluido( - NoConcluido( - evento_id=uuid5(NAMESPACE_URL, f"synapse:{job_id}:{regra_id}:busca_meta"), - job_id=job_id, - regra_id=regra_id, - no="decisao", - concluido_em=datetime.now(UTC), - conclusao=ConclusaoDaTrilha(resumo=resumo), - ) - ) + await producers.no_concluido(trilha) buscas_meta.labels(desfecho=desfecho, motivo=motivo).inc() - busca_meta_candidatos.observe(len(busca["pontos"]) - 1 + (motivo == "falha_execucao")) + busca_meta_candidatos.observe(candidatas) logger.info( "sales target search finished", extra={ diff --git a/codegen/app/graph/nodes/suggest_adaptation.py b/codegen/app/graph/nodes/suggest_adaptation.py index 3679bb20..ba28b565 100644 --- a/codegen/app/graph/nodes/suggest_adaptation.py +++ b/codegen/app/graph/nodes/suggest_adaptation.py @@ -130,8 +130,13 @@ async def suggest_adaptation(state: AgentState, config: RunnableConfig) -> Agent job_id=job_id, no=ETAPA, concluido_em=datetime.now(UTC), - conclusao=ConclusaoDaTrilha(resumo=RESUMOS[alternativa.motivo]), + # `motivo` é texto do conjunto fechado de `app.sugestao_adaptacao`; o modelo da + # mensagem o confere contra o vocabulário do contrato. + conclusao=ConclusaoDaTrilha.model_validate( + {"resumo": RESUMOS[alternativa.motivo], "motivo_sugestao": alternativa.motivo} + ), regra_id=regra_id, + resultado_id=resultado_id, ) ) # A proposta tem o desfecho na simulação, que a decisão conta, e o limite não é uma diff --git a/codegen/app/repositorio/artefatos.py b/codegen/app/repositorio/artefatos.py index fd799f96..bbe11c56 100644 --- a/codegen/app/repositorio/artefatos.py +++ b/codegen/app/repositorio/artefatos.py @@ -191,14 +191,17 @@ class CodigoSimulado: id: UUID prompt_id: UUID fonte: str + #: O resultado de sucesso da simulação deste código: o mais recente, como a api o liga à + #: simulação. + resultado_id: UUID _BUSCAR_CODIGO_SIMULADO = text( - "SELECT c.id, c.prompt_id, c.fonte FROM codigos_gerados c" + "SELECT c.id, c.prompt_id, c.fonte, rs.id AS resultado_id FROM codigos_gerados c" " JOIN resultados_simulacao rs ON rs.codigo_gerado_id = c.id AND rs.job_id = c.job_id" " WHERE c.job_id = :job_id AND c.regra_id = :regra_id" " AND rs.status = 'sucesso' AND rs.proposito = 'simulacao'" - " ORDER BY c.criado_em DESC, c.id DESC LIMIT 1" + " ORDER BY c.criado_em DESC, c.id DESC, rs.criado_em DESC, rs.id DESC LIMIT 1" ) @@ -214,7 +217,10 @@ async def buscar_codigo_simulado( if linha is None: return None return CodigoSimulado( - id=UUID(str(linha["id"])), prompt_id=UUID(str(linha["prompt_id"])), fonte=linha["fonte"] + id=UUID(str(linha["id"])), + prompt_id=UUID(str(linha["prompt_id"])), + fonte=linha["fonte"], + resultado_id=UUID(str(linha["resultado_id"])), ) diff --git a/codegen/tests/app/graph/nodes/test_artifact_nodes.py b/codegen/tests/app/graph/nodes/test_artifact_nodes.py index f3324d39..1d5fe3d2 100644 --- a/codegen/tests/app/graph/nodes/test_artifact_nodes.py +++ b/codegen/tests/app/graph/nodes/test_artifact_nodes.py @@ -10,10 +10,11 @@ from app.codigo_gerado import CodigoInvalidoError from app.contratos.mensagens import EtapaAlterada, ExecutarCodigo from app.contratos.serializacao import serializar +from app.correcao_codigo import ENCAMINHAMENTO_CORRECAO from app.falhas import FalhaDoJobError from app.graph.core.state import AgentState from app.graph.nodes.dispatch_execution import MetaVendaInvalidaError, dispatch_execution -from app.graph.nodes.extract_code import extract_code +from app.graph.nodes.extract_code import RESUMO_DA_CORRECAO, RESUMO_DA_TRILHA, extract_code from app.graph.nodes.persist_response import persist_response from app.representacao_regra import RepresentacaoRegra from tests.app.banco_falso import BancoFalso @@ -99,6 +100,61 @@ async def test_extract_code_anuncia_a_conclusao_do_no_com_as_referencias() -> No assert FONTE not in serializar(evento).decode() +async def test_extract_code_publica_a_geracao_comum_como_gerado_e_sem_resultado() -> None: + producers = _producers() + + await extract_code( + _estado(prompt_id=str(uuid4()), resultado_id=str(uuid4())), _config(BancoFalso(), producers) + ) + + [evento] = producers.no_concluido.await_args.args + assert evento.conclusao.modo_geracao == "gerado" + assert evento.conclusao.resumo == RESUMO_DA_TRILHA + payload = simplejson.loads(serializar(evento), use_decimal=True) + assert "resultado_id" not in payload + assert payload["conclusao"]["modo_geracao"] == "gerado" + oficial("no-concluido").validate(payload) + + +async def test_extract_code_publica_a_correcao_com_o_resultado_que_falhou() -> None: + """O código corrigido aponta o resultado `erro_codigo` de que partiu, que o estado guarda + desde a decisão que pediu a correção.""" + producers = _producers() + falhou = str(uuid4()) + + update = await extract_code( + _estado( + prompt_id=str(uuid4()), resultado_id=falhou, encaminhamento=ENCAMINHAMENTO_CORRECAO + ), + _config(BancoFalso(), producers), + ) + + [evento] = producers.no_concluido.await_args.args + assert evento.conclusao.modo_geracao == "correcao" + assert evento.conclusao.resumo == RESUMO_DA_CORRECAO + assert evento.resultado_id == UUID(falhou) + assert evento.codigo_gerado_id == UUID(update["codigo_gerado_id"]) + payload = simplejson.loads(serializar(evento), use_decimal=True) + assert payload["resultado_id"] == falhou + oficial("no-concluido").validate(payload) + assert FONTE not in serializar(evento).decode() + + +async def test_extract_code_nao_trata_como_correcao_o_encaminhamento_de_outra_etapa() -> None: + producers = _producers() + + await extract_code( + _estado( + prompt_id=str(uuid4()), resultado_id=str(uuid4()), encaminhamento="sugestao_adaptacao" + ), + _config(BancoFalso(), producers), + ) + + [evento] = producers.no_concluido.await_args.args + assert evento.conclusao.modo_geracao == "gerado" + assert evento.resultado_id is None + + async def test_extract_code_repete_o_mesmo_evento_id_numa_reexecucao() -> None: """O id é a chave de idempotência da trilha na api; reexecutar o nó não pode duplicá-la.""" estado = _estado(prompt_id=str(uuid4())) diff --git a/codegen/tests/app/graph/nodes/test_decision.py b/codegen/tests/app/graph/nodes/test_decision.py index 4b4e6e30..00c7611e 100644 --- a/codegen/tests/app/graph/nodes/test_decision.py +++ b/codegen/tests/app/graph/nodes/test_decision.py @@ -13,6 +13,7 @@ from app.correcao_codigo import LIMITE_DE_CODIGOS_POR_VERSAO, FalhaDaExecucao from app.graph.core.state import AgentState from app.graph.nodes import decision as modulo +from app.repositorio.artefatos import id_do_evento_de_trilha from app.repositorio.regras import TentativasDeTaxa from tests.app.graph.nodes.test_suggest_adaptation import ( tentativas_contadas, @@ -216,6 +217,22 @@ async def test_o_resumo_da_trilha_nao_repete_o_veredito() -> None: assert "viável" not in conclusao.conclusao.resumo.lower() +async def test_a_trilha_aponta_o_resultado_decidido_e_a_chave_continua_a_mesma() -> None: + """A linha diz sobre qual resultado a decisão agiu; o `evento_id` não muda com isso.""" + producers = _producers() + + await modulo.decision(_estado(), _config(producers)) + + [conclusao] = producers.no_concluido.await_args.args + assert conclusao.resultado_id == UUID(RESULTADO_ID) + assert conclusao.evento_id == id_do_evento_de_trilha( + UUID(JOB_ID), "decisao", UUID(RESULTADO_ID) + ) + payload = simplejson.loads(serializar(conclusao), use_decimal=True) + assert payload["resultado_id"] == RESULTADO_ID + assert "desfecho_busca" not in payload["conclusao"] + + async def test_o_mesmo_resultado_rende_sempre_o_mesmo_evento_de_trilha() -> None: """Reexecução do nó não pode duplicar a linha da trilha na api.""" producers = _producers() diff --git a/codegen/tests/app/graph/nodes/test_suggest_adaptation.py b/codegen/tests/app/graph/nodes/test_suggest_adaptation.py index 18767325..6f988735 100644 --- a/codegen/tests/app/graph/nodes/test_suggest_adaptation.py +++ b/codegen/tests/app/graph/nodes/test_suggest_adaptation.py @@ -1,6 +1,6 @@ import logging from decimal import Decimal -from typing import Any +from typing import Any, get_args from unittest.mock import AsyncMock, MagicMock from uuid import UUID, uuid4 @@ -9,10 +9,11 @@ from prometheus_client import REGISTRY from app import sugestao_adaptacao -from app.contratos.mensagens import EtapaAlterada +from app.contratos.mensagens import EtapaAlterada, MotivoSugestao from app.contratos.serializacao import serializar from app.graph.core.state import AgentState from app.graph.nodes import suggest_adaptation as modulo +from app.repositorio.artefatos import id_do_evento_de_trilha from app.repositorio.resultados import ResultadoIndisponivelError, TotaisDaSimulacao from app.sugestao_adaptacao import TaxaSimulada from tests.app.test_mensageria import oficial @@ -149,6 +150,27 @@ async def test_propoe_a_alternativa_calculada_sobre_os_totais_apurados( assert contrato["nucleo"]["loja"] == ["13"] +async def test_a_trilha_da_proposta_aponta_o_resultado_analisado_e_diz_o_motivo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A linha diz sobre qual resultado inviável a sugestão agiu; o `evento_id` não muda.""" + _totais(monkeypatch, "492100", "485000") + producers = _producers() + + await modulo.suggest_adaptation(_estado(), _config(producers)) + + [conclusao] = producers.no_concluido.await_args.args + assert conclusao.resultado_id == UUID(RESULTADO_ID) + assert conclusao.conclusao.motivo_sugestao == "proposta" + assert conclusao.evento_id == id_do_evento_de_trilha( + UUID(JOB_ID), "sugestao_adaptacao", UUID(RESULTADO_ID) + ) + payload = simplejson.loads(serializar(conclusao), use_decimal=True) + assert payload["resultado_id"] == RESULTADO_ID + assert payload["conclusao"]["motivo_sugestao"] == "proposta" + oficial("no-concluido").validate(payload) + + async def test_com_comissao_atual_no_escopo_a_primeira_taxa_e_a_que_cabe_no_orcamento( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -253,6 +275,8 @@ async def test_a_terceira_alternativa_que_nao_coube_encerra_sem_quarta_proposta( ) [conclusao] = producers.no_concluido.await_args.args assert conclusao.conclusao.resumo == modulo.RESUMOS["limite"] + assert conclusao.conclusao.motivo_sugestao == "limite" + assert conclusao.resultado_id == UUID(RESULTADO_ID) # A terceira já foi contada como inviável pela decisão; o limite não é outra tentativa. assert variacao_das_tentativas(antes) == {} @@ -311,6 +335,8 @@ async def test_sem_taxa_a_propor_encerra_a_iteracao_sem_alternativa( assert sem_alternativa.status == "sem_alternativa" _, motivo = desfecho assert conclusao.conclusao.resumo == modulo.RESUMOS[motivo] + assert conclusao.conclusao.motivo_sugestao == motivo + assert conclusao.resultado_id == UUID(RESULTADO_ID) assert variacao_das_tentativas(antes) == {desfecho: 1.0} @@ -324,6 +350,15 @@ def test_a_trilha_tem_um_resumo_proprio_para_cada_motivo() -> None: assert len(set(modulo.RESUMOS.values())) == len(motivos) +def test_todo_motivo_da_sugestao_esta_no_vocabulario_do_contrato() -> None: + """Um motivo fora do `motivo_sugestao` do contrato falharia só ao publicar a trilha.""" + motivos = { + valor for nome, valor in vars(sugestao_adaptacao).items() if nome.startswith("MOTIVO_") + } + + assert motivos == set(get_args(MotivoSugestao)) + + async def test_publica_a_proposta_entre_a_etapa_e_a_trilha(monkeypatch: pytest.MonkeyPatch) -> None: _totais(monkeypatch, "492100", "485000") producers = _producers() @@ -550,4 +585,5 @@ async def test_regra_sem_taxa_positiva_encerra_sem_publicar_alternativa( [conclusao] = producers.no_concluido.await_args.args assert conclusao.regra_id == UUID(REGRA_ID) assert conclusao.conclusao.resumo == modulo.RESUMOS["nao_suportada"] + assert conclusao.conclusao.motivo_sugestao == "nao_suportada" assert variacao_das_tentativas(antes) == {("1", "nao_suportada"): 1.0} diff --git a/codegen/tests/app/graph/test_busca_meta.py b/codegen/tests/app/graph/test_busca_meta.py index 09158ce1..d623e592 100644 --- a/codegen/tests/app/graph/test_busca_meta.py +++ b/codegen/tests/app/graph/test_busca_meta.py @@ -5,7 +5,7 @@ from decimal import Decimal from typing import Any from unittest.mock import AsyncMock, MagicMock -from uuid import UUID, uuid4 +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 import pytest import simplejson @@ -33,6 +33,8 @@ class Gravador(Producers): def __init__(self) -> None: self.publicacoes: list[tuple[str, dict[str, Any]]] = [] + # O que o broker recusou, para comparar com o que a reentrega republica. + self.recusadas: list[tuple[str, dict[str, Any]]] = [] self.falhar: str | None = None async def _publicar(self, dto: Saida, nome: str) -> None: @@ -40,6 +42,7 @@ async def _publicar(self, dto: Saida, nome: str) -> None: oficial(nome).validate(payload) if nome == self.falhar: self.falhar = None + self.recusadas.append((nome, payload)) raise RuntimeError("broker indisponível") self.publicacoes.append((nome, payload)) @@ -210,6 +213,34 @@ async def test_busca_publica_sem_solucao_com_motivo(ambiente: Ambiente, motivo: ) +@pytest.mark.parametrize( + "modo", ["sucesso", "nao_monotonica", "intervalo_esgotado", "falha_execucao"] +) +async def test_a_linha_que_encerra_a_busca_publica_a_vencedora_o_desfecho_e_a_contagem( + ambiente: Ambiente, modo: str +) -> None: + await ambiente.iniciar() + sugerida = await ambiente.completar(modo) + + decisoes = [p for p in ambiente.producers.de("no-concluido") if p["no"] == "decisao"] + abertura, encerramento = decisoes + # A decisão que abriu a busca age sobre a simulação original e não traz desfecho de busca. + assert abertura["resultado_id"] == str(ambiente.original) + assert "desfecho_busca" not in abertura["conclusao"] + assert "candidatas_simuladas" not in abertura["conclusao"] + # A linha que encerra a busca mantém a chave própria, derivada do job e da versão da regra. + assert encerramento["evento_id"] == str( + uuid5(NAMESPACE_URL, f"synapse:{ambiente.job}:{ambiente.regra}:busca_meta") + ) + conclusao = encerramento["conclusao"] + assert conclusao["desfecho_busca"] == ("encontrada" if modo == "sucesso" else modo) + assert conclusao["candidatas_simuladas"] == len(ambiente.producers.de("executar-codigo")) + if modo == "sucesso": + assert encerramento["resultado_id"] == sugerida["resultado_id"] + else: + assert "resultado_id" not in encerramento + + async def test_resultados_antigos_nao_avancam_a_candidata_pendente(ambiente: Ambiente) -> None: await ambiente.iniciar() primeiro = ambiente.gravar(Decimal("100")) @@ -289,6 +320,21 @@ async def test_logs_e_metricas_do_fluxo( assert amostra(depois, "codegen_busca_meta_processamento_seconds_count", {}) > amostra( antes, "codegen_busca_meta_processamento_seconds_count", {} ) + # A mesma contagem vai à métrica e à trilha, e a busca é observada uma vez. + [encerramento] = [ + p for p in ambiente.producers.de("no-concluido") if "desfecho_busca" in p["conclusao"] + ] + candidatas = encerramento["conclusao"]["candidatas_simuladas"] + assert ( + amostra(depois, "codegen_busca_meta_candidatos_sum", {}) + - amostra(antes, "codegen_busca_meta_candidatos_sum", {}) + == candidatas + ) + assert ( + amostra(depois, "codegen_busca_meta_candidatos_count", {}) + - amostra(antes, "codegen_busca_meta_candidatos_count", {}) + == 1 + ) schema = simplejson.loads((CONTRATOS / "observability/log.schema.json").read_bytes()) validador = Draft202012Validator(schema, format_checker=FormatChecker()) busca = [linha for linha in logs if linha["message"].startswith("sales target")] @@ -355,6 +401,32 @@ async def test_republicacao_do_desfecho_preserva_meta_resultado_e_identidade( assert len(ambiente.producers.de("executar-codigo")) == 1 +@pytest.mark.parametrize("modo", ["sucesso", "nao_monotonica"]) +async def test_a_republicacao_do_encerramento_repete_a_linha_da_trilha( + ambiente: Ambiente, modo: str +) -> None: + """A linha que encerra a busca falhou no broker; a retomada a republica igual, com a mesma + chave, a mesma vencedora e a mesma contagem.""" + await ambiente.iniciar() + ambiente.producers.falhar = "no-concluido" + with pytest.raises(RuntimeError, match="broker"): + await ambiente.completar(modo) + snapshot = await build_graph(ambiente.saver).aget_state(ambiente.config) + + await ambiente.retomar(UUID(snapshot.values["resultado_id"])) + + [(_, recusada)] = ambiente.producers.recusadas + republicada = ambiente.producers.de("no-concluido")[-1] + assert republicada["evento_id"] == recusada["evento_id"] + assert republicada.get("resultado_id") == recusada.get("resultado_id") + assert republicada["conclusao"] == recusada["conclusao"] + if modo == "sucesso": + assert ( + republicada["resultado_id"] + == ambiente.producers.de("meta-venda-sugerida")[-1]["resultado_id"] + ) + + async def test_regra_com_especificacoes_tambem_busca_meta(ambiente: Ambiente) -> None: """A busca simula o mesmo código, com todos os elementos da regra, nas metas candidatas.""" ambiente.estado["representacao_regra"]["especificacoes"] = [ diff --git a/codegen/tests/app/graph/test_correcao_do_codigo.py b/codegen/tests/app/graph/test_correcao_do_codigo.py index 4eaf6db7..1ecd5bfd 100644 --- a/codegen/tests/app/graph/test_correcao_do_codigo.py +++ b/codegen/tests/app/graph/test_correcao_do_codigo.py @@ -67,14 +67,15 @@ async def regra_do_resultado(self, sessoes: Any, job_id: UUID, resultado_id: UUI return self.regras_das_falhas[resultado_id] return await self.job.historico.buscar_regra_do_resultado(sessoes, job_id, resultado_id) - async def falhar( + def gravar_falha( self, regra_id: UUID, status: str = "erro_codigo", *, diagnostico: dict[str, Any] | None = None, assercoes: list[dict[str, Any]] | None = None, - ) -> None: + ) -> dict[str, Any]: + """A falha gravada, e o `simulacao-concluida` que o worker publica sobre ela.""" resultado_id = UUID(int=len(self.falhas) + 1) self.regras_das_falhas[resultado_id] = regra_id self.falhas[resultado_id] = FalhaDaExecucao( @@ -82,12 +83,22 @@ async def falhar( diagnostico if diagnostico is not None else EXCECAO, [a for a in assercoes or [] if a["resultado"] == "violada"], ) + return { + "job_id": str(self.job.historico.job_id), + "resultado_id": str(resultado_id), + "status": status, + } + + async def falhar( + self, + regra_id: UUID, + status: str = "erro_codigo", + *, + diagnostico: dict[str, Any] | None = None, + assercoes: list[dict[str, Any]] | None = None, + ) -> None: recebida = await self.job.entregar( - { - "job_id": str(self.job.historico.job_id), - "resultado_id": str(resultado_id), - "status": status, - } + self.gravar_falha(regra_id, status, diagnostico=diagnostico, assercoes=assercoes) ) recebida.ack.assert_awaited_once_with() @@ -142,6 +153,10 @@ def _encaminhamentos(job: Job) -> list[str]: ] +def _geracoes(job: Job) -> list[dict[str, Any]]: + return [p for p in job.publicadas("no-concluido") if p["no"] == "geracao_codigo"] + + async def test_codigo_que_falha_volta_ao_modelo_e_o_corrigido_segue_para_a_decisao( monkeypatch: pytest.MonkeyPatch, logs: list[dict[str, Any]] ) -> None: @@ -155,6 +170,14 @@ async def test_codigo_que_falha_volta_ao_modelo_e_o_corrigido_segue_para_a_decis [primeiro, corrigido] = [c for _, c in worker.codigos()] assert primeiro != corrigido + # A linha do código corrigido aponta o resultado `erro_codigo` de que ele partiu. + [falhou] = worker.falhas + primeira, segunda = _geracoes(job) + assert primeira["conclusao"]["modo_geracao"] == "gerado" + assert "resultado_id" not in primeira + assert segunda["conclusao"]["modo_geracao"] == "correcao" + assert segunda["resultado_id"] == str(falhou) + assert segunda["codigo_gerado_id"] == corrigido comandos = job.publicadas("executar-codigo") assert [c["codigo_gerado_id"] for c in comandos] == [primeiro, corrigido] [_, prompt_da_correcao] = job.ambiente.modelo.seen_messages @@ -181,6 +204,30 @@ async def test_codigo_que_falha_volta_ao_modelo_e_o_corrigido_segue_para_a_decis assert pedido["extra"]["posicao_do_codigo"] == 1 +async def test_a_reentrega_republica_a_trilha_do_codigo_corrigido_com_o_mesmo_evento_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A trilha do código corrigido falhou no broker; a reentrega do resultado que falhou + reexecuta a extração, e a linha republicada é a mesma.""" + job = Job(monkeypatch) + worker = Worker(job, monkeypatch) + regra = job.original() + await job.abrir_ciclo(regra) + recusadas = job.recusar_uma_trilha(lambda p: p["conclusao"].get("modo_geracao") == "correcao") + falha = worker.gravar_falha(regra) + interrompida = await job.entregar(falha) + interrompida.nack.assert_awaited_once_with(requeue=True) + + repetida = await job.entregar(falha) + + repetida.ack.assert_awaited_once_with() + [recusada] = recusadas + [republicada] = [g for g in _geracoes(job) if g["conclusao"]["modo_geracao"] == "correcao"] + assert republicada["evento_id"] == recusada["evento_id"] + assert republicada["resultado_id"] == recusada["resultado_id"] == falha["resultado_id"] + assert republicada["codigo_gerado_id"] == recusada["codigo_gerado_id"] + + async def test_terceiro_codigo_que_falha_encerra_sem_nova_geracao( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -196,6 +243,13 @@ async def test_terceiro_codigo_que_falha_encerra_sem_nova_geracao( assert len(job.publicadas("executar-codigo")) == 3 assert len(job.ambiente.modelo.seen_messages) == 3 + # Cada correção parte do resultado que falhou logo antes dela. + primeiro_falhou, segundo_falhou, _ = worker.falhas + assert [(g["conclusao"]["modo_geracao"], g.get("resultado_id")) for g in _geracoes(job)] == [ + ("gerado", None), + ("correcao", str(primeiro_falhou)), + ("correcao", str(segundo_falhou)), + ] assert _encaminhamentos(job) == ["correcao_codigo", "correcao_codigo", "fim"] assert _variacao(*PEDIDOS, pedidos) == { ("2", "excecao"): 1.0, @@ -220,6 +274,12 @@ async def test_alternativa_da_taxa_nova_que_falha_e_corrigida_e_a_iteracao_conti await job.simular(alternativa, Decimal("1800")) assert [p["tentativa"] for p in job.propostas()] == [1, 2] + [falhou] = worker.falhas + assert [(g["conclusao"]["modo_geracao"], g.get("resultado_id")) for g in _geracoes(job)] == [ + ("gerado", None), + ("gerado", None), + ("correcao", str(falhou)), + ] assert [r for r, _ in worker.codigos()] == [ str(original), str(alternativa), diff --git a/codegen/tests/app/graph/test_derivacao_do_codigo.py b/codegen/tests/app/graph/test_derivacao_do_codigo.py index 68538b96..56165f2f 100644 --- a/codegen/tests/app/graph/test_derivacao_do_codigo.py +++ b/codegen/tests/app/graph/test_derivacao_do_codigo.py @@ -53,13 +53,21 @@ def _contagem(desfecho: str) -> float: return REGISTRY.get_sample_value(METRICA, {"desfecho": desfecho}) or 0.0 -async def _ate_a_alternativa( +async def _ate_a_proposta( monkeypatch: pytest.MonkeyPatch, fonte_da_origem: str -) -> tuple[Job, UUID, CodigoSimulado]: - """A original simulada sem caber, a proposta publicada e o ciclo da alternativa aberto.""" +) -> tuple[Job, dict[str, Any], CodigoSimulado]: + """A original simulada sem caber e a proposta publicada, antes do ciclo da alternativa.""" job = Job(monkeypatch) original = job.original() - codigo_da_origem = CodigoSimulado(id=uuid4(), prompt_id=uuid4(), fonte=fonte_da_origem) + await job.abrir_ciclo(original) + simulada = await job.simular(original, Decimal("2200")) + # O código da origem com o resultado de sucesso da simulação dele, como a consulta devolve. + codigo_da_origem = CodigoSimulado( + id=uuid4(), + prompt_id=uuid4(), + fonte=fonte_da_origem, + resultado_id=UUID(simulada["resultado_id"]), + ) async def buscar_codigo_simulado(sessoes: Any, job_id: UUID, regra_id: UUID) -> Any: return codigo_da_origem if regra_id == original else None @@ -68,9 +76,15 @@ async def buscar_codigo_simulado(sessoes: Any, job_id: UUID, regra_id: UUID) -> "app.graph.nodes.derive_code.buscar_codigo_simulado", AsyncMock(side_effect=buscar_codigo_simulado), ) - await job.abrir_ciclo(original) - await job.simular(original, Decimal("2200")) (proposta,) = job.propostas() + return job, proposta, codigo_da_origem + + +async def _ate_a_alternativa( + monkeypatch: pytest.MonkeyPatch, fonte_da_origem: str +) -> tuple[Job, UUID, CodigoSimulado]: + """A original simulada sem caber, a proposta publicada e o ciclo da alternativa aberto.""" + job, proposta, codigo_da_origem = await _ate_a_proposta(monkeypatch, fonte_da_origem) alternativa = await job.alternativa(proposta) return job, alternativa, codigo_da_origem @@ -99,6 +113,8 @@ async def test_alternativa_recebe_o_codigo_da_origem_com_a_taxa_nova_sem_chamar_ if n.get("codigo_gerado_id") == str(derivado["id"]) and n["no"] == "geracao_codigo" ] assert trilha["conclusao"]["resumo"] == RESUMO_DA_TRILHA + assert trilha["conclusao"]["modo_geracao"] == "derivado" + assert trilha["resultado_id"] == str(origem.resultado_id) assert trilha["prompt_id"] == str(origem.prompt_id) assert _contagem("derivado") == antes + 1 (log,) = [e for e in logs if e["message"] == "alternative code derived from the origin code"] @@ -108,6 +124,31 @@ async def test_alternativa_recebe_o_codigo_da_origem_com_a_taxa_nova_sem_chamar_ assert "aplicar_regra" not in simplejson.dumps(logs) +async def test_a_reentrega_republica_a_trilha_do_codigo_derivado_com_o_mesmo_evento_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A trilha do código derivado falhou no broker; a reentrega do `regra-submetida` da + alternativa reexecuta a derivação, e a linha republicada é a mesma.""" + job, proposta, origem = await _ate_a_proposta(monkeypatch, FONTE_COM_A_TAXA) + recusadas = job.recusar_uma_trilha(lambda p: p["conclusao"].get("modo_geracao") == "derivado") + alternativa = job.historico.gravar_proposta(proposta) + interrompida = await job.submeter(alternativa) + interrompida.nack.assert_awaited_once_with(requeue=True) + + repetida = await job.submeter(alternativa) + + repetida.ack.assert_awaited_once_with() + [recusada] = recusadas + [republicada] = [ + n + for n in job.publicadas("no-concluido") + if n["no"] == "geracao_codigo" and n["regra_id"] == str(alternativa) + ] + assert republicada["evento_id"] == recusada["evento_id"] + assert republicada["resultado_id"] == recusada["resultado_id"] == str(origem.resultado_id) + assert republicada["codigo_gerado_id"] == recusada["codigo_gerado_id"] + + async def test_alternativa_cujo_codigo_nao_escreve_a_taxa_e_gerada_pelo_modelo( monkeypatch: pytest.MonkeyPatch, logs: list[dict[str, Any]] ) -> None: @@ -120,6 +161,14 @@ async def test_alternativa_cujo_codigo_nao_escreve_a_taxa_e_gerada_pelo_modelo( alternativa ) == 1 assert _contagem("taxas_nao_localizadas") == antes + 1 + # A alternativa que o modelo gera não é derivada: a linha diz `gerado` e não aponta resultado. + [gerada] = [ + n + for n in job.publicadas("no-concluido") + if n["no"] == "geracao_codigo" and n["regra_id"] == str(alternativa) + ] + assert gerada["conclusao"]["modo_geracao"] == "gerado" + assert "resultado_id" not in gerada (log,) = [ e for e in logs if e["message"] == "alternative code not derived; generating with the model" ] diff --git a/codegen/tests/app/graph/test_iteracao_da_taxa_nova.py b/codegen/tests/app/graph/test_iteracao_da_taxa_nova.py index 1e68bb4c..98d422e6 100644 --- a/codegen/tests/app/graph/test_iteracao_da_taxa_nova.py +++ b/codegen/tests/app/graph/test_iteracao_da_taxa_nova.py @@ -7,7 +7,7 @@ """ import logging -from collections.abc import Iterator +from collections.abc import Callable, Iterator from decimal import Decimal from typing import Any from uuid import UUID, uuid4 @@ -19,6 +19,7 @@ from prometheus_client.parser import text_string_to_metric_families from app.contratos.mensagens import RegraSubmetida, SimulacaoConcluida +from app.contratos.serializacao import serializar from app.core.logger import FormatadorJson, ManipuladorFilaContexto from app.mensageria.consumers import Consumer from tests.app.confirmacao_falsa import ConfirmacaoFalsa, mensagem @@ -54,7 +55,7 @@ def original(self, especificacoes: list[Any] | None = None) -> UUID: {"nucleo": dict(NUCLEO), "especificacoes": especificacoes or []}, "extracao" ) - async def abrir_ciclo(self, regra_id: UUID) -> None: + async def submeter(self, regra_id: UUID) -> Any: """O `regra-submetida` que a api publica ao gravar a versão, com os parâmetros do job.""" payload = exemplo("regra-submetida-parametros") | { "job_id": str(self.historico.job_id), @@ -64,11 +65,33 @@ async def abrir_ciclo(self, regra_id: UUID) -> None: if not self.com_orcamento: del payload["orcamento"] recebida = mensagem(payload) - await self.submissao.receber(recebida) + return recebida + + async def abrir_ciclo(self, regra_id: UUID) -> None: + recebida = await self.submeter(regra_id) recebida.ack.assert_awaited_once_with() + def recusar_uma_trilha(self, recusa: Callable[[dict[str, Any]], bool]) -> list[dict[str, Any]]: + """O broker recusa a primeira linha da trilha que `recusa` reconhece, uma vez só. + + A lista devolvida recebe o payload recusado, para comparar com o que a reentrega + republica. + """ + publicar = self.ambiente.producers.no_concluido.side_effect + recusadas: list[dict[str, Any]] = [] + + async def recusar_uma_vez(dto: Any) -> None: + payload = simplejson.loads(serializar(dto), use_decimal=True) + if not recusadas and recusa(payload): + recusadas.append(payload) + raise RuntimeError("broker fora") + await publicar(dto) + + self.ambiente.producers.no_concluido.side_effect = recusar_uma_vez + return recusadas + def resultado_do_worker(self, regra_id: UUID, simulado: Decimal) -> dict[str, Any]: resultado_id = self.historico.gravar_resultado(regra_id, simulado) if not self.com_orcamento: @@ -161,12 +184,24 @@ async def test_tres_taxas_que_nao_cabem_encerram_a_iteracao_sem_alternativa( origem, *simuladas = COMISSOES_QUE_NAO_CABEM versoes_simuladas = [regra] - await job.simular(regra, origem) + resultados = [(await job.simular(regra, origem))["resultado_id"]] for comissao in simuladas: alternativa = await job.alternativa(job.propostas()[-1]) versoes_simuladas.append(alternativa) - await job.simular(alternativa, comissao) - + resultados.append((await job.simular(alternativa, comissao))["resultado_id"]) + + # Para cada simulação inviável, a decisão e a sugestão apontam o mesmo resultado, e a + # sugestão diz se propôs uma taxa ou parou no limite das três tentativas. + trilhas = job.publicadas("no-concluido") + assert [t["resultado_id"] for t in trilhas if t["no"] == "decisao"] == resultados + sugestoes = [t for t in trilhas if t["no"] == "sugestao_adaptacao"] + assert [t["resultado_id"] for t in sugestoes] == resultados + assert [t["conclusao"]["motivo_sugestao"] for t in sugestoes] == [ + "proposta", + "proposta", + "proposta", + "limite", + ] propostas = job.propostas() assert [p["tentativa"] for p in propostas] == [1, 2, 3] assert [percentual(p) for p in propostas] == [ @@ -336,6 +371,29 @@ async def test_reentrega_do_resultado_ja_processado_nao_publica_nada( assert job.ambiente.publicacoes == publicadas +async def test_a_reentrega_republica_a_trilha_da_sugestao_com_o_mesmo_evento_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A trilha da sugestão falhou no broker; a reentrega do resultado reexecuta o nó, e a + linha republicada é a mesma, para a api reconhecer a reentrega pelo `evento_id`.""" + job = Job(monkeypatch) + regra = job.original() + await job.abrir_ciclo(regra) + recusadas = job.recusar_uma_trilha(lambda p: p["no"] == "sugestao_adaptacao") + resultado = job.resultado_do_worker(regra, Decimal("2200")) + interrompida = await job.entregar(resultado) + interrompida.nack.assert_awaited_once_with(requeue=True) + + repetida = await job.entregar(resultado) + + repetida.ack.assert_awaited_once_with() + [recusada] = recusadas + [republicada] = [p for p in job.publicadas("no-concluido") if p["no"] == "sugestao_adaptacao"] + assert republicada["evento_id"] == recusada["evento_id"] + assert republicada["resultado_id"] == recusada["resultado_id"] == resultado["resultado_id"] + assert republicada["conclusao"] == recusada["conclusao"] + + async def test_reentrega_depois_de_a_api_gravar_a_proposta_repete_a_mesma_tentativa( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/codegen/tests/app/repositorio/test_derivacao_postgres.py b/codegen/tests/app/repositorio/test_derivacao_postgres.py index 45d2a4e5..38033380 100644 --- a/codegen/tests/app/repositorio/test_derivacao_postgres.py +++ b/codegen/tests/app/repositorio/test_derivacao_postgres.py @@ -55,7 +55,7 @@ async def test_o_codigo_simulado_e_o_ultimo_da_versao_com_sucesso_na_simulacao( job = await historico.job() regra = await historico.versao(job, 1, "extracao", "0.03") que_passou = await historico.codigo(job, regra) - await historico.resultado(job, regra, "1000", codigo=que_passou) + resultado_que_passou = await historico.resultado(job, regra, "1000", codigo=que_passou) que_falhou = await historico.codigo(job, regra) await historico.resultado(job, regra, None, status="erro_codigo", codigo=que_falhou) da_busca = await historico.codigo(job, regra) @@ -66,6 +66,26 @@ async def test_o_codigo_simulado_e_o_ultimo_da_versao_com_sucesso_na_simulacao( assert simulado is not None assert simulado.id == que_passou assert simulado.fonte == "f" + # O resultado da simulação que passou, e não o da falha nem o da execução da busca. + assert simulado.resultado_id == resultado_que_passou + + +async def test_com_dois_sucessos_do_mesmo_codigo_a_origem_e_o_resultado_mais_recente( + banco: tuple[async_sessionmaker[AsyncSession], Historico], +) -> None: + """O mesmo resultado que a api liga à simulação do código, e não um qualquer dos dois.""" + sessoes, historico = banco + job = await historico.job() + regra = await historico.versao(job, 1, "extracao", "0.03") + codigo = await historico.codigo(job, regra) + await historico.resultado(job, regra, "1000", codigo=codigo) + mais_recente = await historico.resultado(job, regra, "1000", codigo=codigo) + + simulado = await buscar_codigo_simulado(sessoes, job, regra) + + assert simulado is not None + assert simulado.id == codigo + assert simulado.resultado_id == mais_recente async def test_versao_sem_codigo_simulado_com_sucesso_nao_tem_origem( diff --git a/codegen/tests/app/test_busca_meta_integracao.py b/codegen/tests/app/test_busca_meta_integracao.py index 67472ad6..8e8dc53a 100644 --- a/codegen/tests/app/test_busca_meta_integracao.py +++ b/codegen/tests/app/test_busca_meta_integracao.py @@ -273,6 +273,17 @@ async def test_busca_com_broker_banco_checkpoint_e_scrape_reais( ] assert all(t["no"] == "decisao" for t in trilhas) assert trilhas[0]["evento_id"] != trilhas[1]["evento_id"] + abertura, encerramento = trilhas + assert abertura["resultado_id"] == original["resultado_id"] + assert "desfecho_busca" not in abertura["conclusao"] + assert encerramento["conclusao"]["desfecho_busca"] == ( + "encontrada" if modo == "sucesso" else modo + ) + assert encerramento["conclusao"]["candidatas_simuladas"] == len(candidatas) + if modo == "sucesso": + assert encerramento["resultado_id"] == sugerida["resultado_id"] + else: + assert "resultado_id" not in encerramento depois = (await cliente_busca.get("/metrics")).text rotulos = { "desfecho": "sugerida" if modo == "sucesso" else "sem_solucao", diff --git a/codegen/tests/app/test_mensageria.py b/codegen/tests/app/test_mensageria.py index 968cf53c..1c4aedf6 100644 --- a/codegen/tests/app/test_mensageria.py +++ b/codegen/tests/app/test_mensageria.py @@ -11,6 +11,7 @@ import simplejson from aio_pika import DeliveryMode from jsonschema import Draft202012Validator, FormatChecker +from pydantic import ValidationError from referencing import Registry, Resource from app.codigo_gerado import CodigoInvalidoError @@ -75,10 +76,14 @@ async def test_consumer_entrega_dto_canonico_com_decimal_exato_e_campo_futuro() (SimulacaoConcluida, "simulacao-concluida"), (JobEncerrado, "job-encerrado"), ) +# (modelo, nome do exemplo, rota, método do producer). O exemplo e a rota só coincidem quando o +# evento tem um exemplo só: as variantes do `no-concluido` são publicadas na mesma rota. SAIDAS = ( - (ExecutarCodigo, "executar-codigo", "executar_codigo"), - (EtapaAlterada, "etapa-alterada", "etapa_alterada"), - (NoConcluido, "no-concluido", "no_concluido"), + (ExecutarCodigo, "executar-codigo", "executar-codigo", "executar_codigo"), + (EtapaAlterada, "etapa-alterada", "etapa-alterada", "etapa_alterada"), + (NoConcluido, "no-concluido", "no-concluido", "no_concluido"), + (NoConcluido, "no-concluido-geracao-derivada", "no-concluido", "no_concluido"), + (NoConcluido, "no-concluido-encerramento-busca", "no-concluido", "no_concluido"), ) @@ -218,6 +223,7 @@ async def test_producer_preserva_decimal_exato_no_corpo_json() -> None: {"concluido_em": "2025-11-28T14:32:10"}, {"conclusao": {"resumo": "ok", "fontes": None}}, {"explicacao_id": None}, + {"resultado_id": None}, ], ) async def test_producer_recusa_data_sem_fuso_e_null_explicito( @@ -233,10 +239,29 @@ async def test_producer_recusa_data_sem_fuso_e_null_explicito( canal.default_exchange.publish.assert_not_awaited() -@pytest.mark.parametrize("modelo,nome,metodo", SAIDAS) +@pytest.mark.parametrize( + "conclusao", + [ + {"resumo": "ok", "modo_geracao": "inventado"}, + {"resumo": "ok", "motivo_sugestao": "inventado"}, + {"resumo": "ok", "desfecho_busca": "inventado"}, + {"resumo": "ok", "candidatas_simuladas": -1}, + ], +) +def test_a_conclusao_recusa_variante_fora_do_vocabulario_do_contrato( + conclusao: dict[str, Any], +) -> None: + with pytest.raises(ValidationError): + NoConcluido.model_validate_json( + simplejson.dumps(exemplo("no-concluido") | {"conclusao": conclusao}) + ) + + +@pytest.mark.parametrize("modelo,nome,rota,metodo", SAIDAS) async def test_producer_publica_payload_validado_na_rota_correta( modelo: type[ModeloContrato], nome: str, + rota: str, metodo: str, ) -> None: canal = MagicMock() @@ -246,21 +271,21 @@ async def test_producer_publica_payload_validado_na_rota_correta( await getattr(Producers(canal), metodo)(dto) args = canal.default_exchange.publish.call_args - assert args.kwargs == {"routing_key": nome, "mandatory": True} + assert args.kwargs == {"routing_key": rota, "mandatory": True} mensagem = args.args[0] - oficial(nome).validate(simplejson.loads(mensagem.body, use_decimal=True)) + oficial(rota).validate(simplejson.loads(mensagem.body, use_decimal=True)) assert mensagem.delivery_mode == DeliveryMode.PERSISTENT assert mensagem.correlation_id == str(dto.job_id) assert mensagem.content_type == "application/json" assert mensagem.content_encoding == "utf-8" - assert mensagem.type == nome + assert mensagem.type == rota assert mensagem.message_id == (str(dto.evento_id) if isinstance(dto, NoConcluido) else None) assert simplejson.loads(mensagem.body, use_decimal=True) == exemplo(nome) -@pytest.mark.parametrize("modelo,nome,metodo", SAIDAS) +@pytest.mark.parametrize("modelo,nome,rota,metodo", SAIDAS) async def test_producer_nao_publica_dto_adulterado( - modelo: type[ModeloContrato], nome: str, metodo: str + modelo: type[ModeloContrato], nome: str, rota: str, metodo: str ) -> None: canal = MagicMock() canal.default_exchange.publish = AsyncMock() diff --git a/codegen/tests/app/test_rabbitmq_integracao.py b/codegen/tests/app/test_rabbitmq_integracao.py index 465b2a99..1065b5a6 100644 --- a/codegen/tests/app/test_rabbitmq_integracao.py +++ b/codegen/tests/app/test_rabbitmq_integracao.py @@ -221,23 +221,24 @@ async def test_fanout_mantem_copia_na_fila_api(broker_real: ConexaoBroker) -> No await copia_api.ack() -@pytest.mark.parametrize("modelo,nome,metodo", SAIDAS) +@pytest.mark.parametrize("modelo,nome,rota,metodo", SAIDAS) async def test_producer_real_entrega_payload_oficial_na_fila( broker_real: ConexaoBroker, modelo: type[ModeloContrato], nome: str, + rota: str, metodo: str, ) -> None: dto = modelo.model_validate_json(simplejson.dumps(exemplo(nome))) await getattr(broker_real.producers, metodo)(dto) - recebido = await broker_real.filas[nome].get(timeout=10) + recebido = await broker_real.filas[rota].get(timeout=10) payload = simplejson.loads(recebido.body, use_decimal=True) - oficial(nome).validate(payload) + oficial(rota).validate(payload) assert payload == exemplo(nome) assert recebido.exchange == "" - assert recebido.routing_key == nome + assert recebido.routing_key == rota await recebido.ack()