Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions codegen/app/contratos/mensagens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions codegen/app/graph/nodes/decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion codegen/app/graph/nodes/derive_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
35 changes: 24 additions & 11 deletions codegen/app/graph/nodes/extract_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)}
32 changes: 21 additions & 11 deletions codegen/app/graph/nodes/search_sales_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.busca_meta import Ponto, escolher
from app.contratos.mensagens import (
ConclusaoDaTrilha,
DesfechoBusca,
MetaVendaSugerida,
NoConcluido,
SemSolucaoMeta,
Expand Down Expand Up @@ -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"])
Expand All @@ -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={
Expand Down
7 changes: 6 additions & 1 deletion codegen/app/graph/nodes/suggest_adaptation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions codegen/app/repositorio/artefatos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)


Expand All @@ -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"])),
)


Expand Down
58 changes: 57 additions & 1 deletion codegen/tests/app/graph/nodes/test_artifact_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))
Expand Down
17 changes: 17 additions & 0 deletions codegen/tests/app/graph/nodes/test_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading