76 KiB
ConversationRuntime: especificação técnica de implementação
Especificação técnica do pacote agent/conversation/. Documento autocontido, escrito para guiar a implementação. Cada seção detalha um arquivo do pacote com sua API pública, tipos esperados, contratos e critérios de aceite. Quem escreve código aqui consulta este arquivo como referência viva.
Status de implementação
Este spec é o norte (visão completa).
state.py,intent_extractor.py,mutations.pyeserialization.pyestão implementados; a cadeia de ação da Fase 2 (invoice_resolver→action_queue→confirmation_manager→action_executor→finalization_machine) está ligada noruntime.py, e oresponse_composercobreconfirmation()(templates dinâmicos) eitem_not_found().script_catalog+scripts/*.yamlseguem como roadmap.Completo:
intent_extractor.py:IntentKind(taxonomia de 10),ExtractedIntent,IntentClassifierContext,IntentClassifierLLM(Protocol),INTENT_CLASSIFICATION_SCHEMA,IntentExtractor(fast-path AFFIRMATIONS/NEGATIONS/END_PHRASES) eCapabilityIntentClassifier(capabilityconversation_intent_classifier+ fallback inline).state.py: enums (ConversationStatus/Channel/ToolKind/ToolOutcome), value objects (ResolvedInvoiceItem/ActionStep/PendingConfirmation/ResolvedAction),ActionQueueeConversationState(campos do spec) +to_prompt_context().mutations.py: os 15 hooks.serialization.py: (de)serialização completa (enums/value objects/fila), com reset em drift de versão.Cobertura atual da Policy/runtime: a
PolicyEnginetrata deterministicamente:confirmation_response+ pending →execute_action_queue;action_requestcom menção ambígua (2+ matches distintos) →fallback_to_orchestratorcomreason="ambiguous_item_match"(runtime anexa uma diretiva curta à fala do cliente viaRuntimeResult.orchestrator_directive, não ao system prompt);action_requestcom items →ask_confirmation, sem items →item_not_found;conceptual_question→ RAG inline (consulta =rag_queryrefinada, senão fala literal; query vazia →rag_no_query);human_or_end→ finalização. Off-context é contado no estado e roteia para o orquestrador (regra terminal de off-context segue roadmap). Demais intenções →fallback_to_orchestrator(comconversation_state_summaryinjetado).Divergências duráveis (decididas):
conceptual_question→ RAG inline no runtime (decisão de produto), em vez decall_informative_tooldelegando ao orquestrador. As consultas do RAG são as queries refinadas pelo classificador (ExtractedIntent.rag_query, uma por conceito, ex.:["tamboro o que é", "tim fashion o que é"]), buscadas em paralelo pelo backend; na ausência delas, cai na fala literal, e se nada sobra o runtime devolverag_no_query. (Revoga a decisão anterior de usar sempre a fala crua do cliente, que recuperava mal na base vetorial — ex.: "um tal de tamboro" não casava documento algum.)IntentClassifierLLM.classifyrecebecallbacksopcional (observabilidade do span do classificador).turn_indexé incrementado pela própria camada emon_turn_started(ela é dona do contador), não espelhado do_turn_indexdo core.serializationreseta em mismatch de versão (sessões SSE curtas); migração em cadeia fica para quando o estado evoluir.Roadmap: Fase 2 (
invoice_resolver/action_queue/confirmation_manager/action_executor+ regrasask_confirmation/execute_action_queue/call_informative_tool/redirect_off_contextda Policy) →finalization_machine+response_composer→script_catalog+scripts/*.yaml.
Sumário
- Visão geral
- Princípios fundamentais
- Governança anti-inflação da camada
- Estrutura de arquivos
- Convenções de código
- state.py
- mutations.py
- intent_extractor.py
- invoice_resolver.py
- action_queue.py
- confirmation_manager.py
- policy_engine.py
- action_executor.py
- finalization_machine.py
- script_catalog.py
- response_composer.py
- serialization.py
- runtime.py
- Testes
- Integração com o agente existente
Visão geral
O pacote agent/conversation/ implementa a camada de domínio do diálogo. Recebe a mensagem do cliente, mantém estado canônico do turno, decide deterministicamente o próximo passo, executa ações via backends existentes e gera fala canônica quando aplicável. Convive com o RunLoopExecutor atual via mecanismo de fallback por intenção.
Ponto de entrada único. ConversationRuntime.try_handle(...) em runtime.py.
Resultado esperado. Um RuntimeResult que indica se o turno foi resolvido pelo runtime (handled=True com payload de resposta) ou se cai para o orquestrador atual (handled=False com fallback_reason).
Modelo de coexistência. Dois mecanismos garantem reversibilidade.
fallback_to_orchestratorpor intenção: aPolicyEnginecobre um conjunto fechado de intenções. Intenções não cobertas devolvemfallback_to_orchestratore oRunLoopExecutoratual assume o turno com acesso ao estado serializado viato_prompt_context().- Variável de ambiente
CONVERSATION_RUNTIME_ENABLEDcomo kill switch global. Quando desligada, todo turno vai direto para oRunLoopExecutor, ignorando o runtime. Padrão desligada em produção até aprovação do checkpoint de pré-produção.
Princípios fundamentais
Internalize antes de codar qualquer arquivo da camada.
- Estado canônico como memória estruturada. O
ConversationStateé consumível por múltiplos atores:PolicyEngine(para decisão determinística),IntentExtractor(para montar contexto de classificação), LLM via prompt (no caminho de fallback e como contexto auxiliar), telemetria, retry de reasoning leak e o próprio runtime para roteamento interno. Um campo pertence ao estado se tem pelo menos um consumidor estruturado entre esses seis. Consumidores são read-only; mutação só por hook. - Função pura de decisão. A
PolicyEngineé função pura sobre o estado, sem efeitos colaterais, sem chamadas externas, sem logs internos. Devolve uma decisão estruturada. - Tipos fechados em todos os contratos. Enums e literais ao invés de strings mágicas e dicts soltos. Validação de runtime barata e testes triviais.
- Mutação só por hook nomeado. Não há atribuição direta a campos do estado fora de
mutations.py. Cada mutação é evento de domínio com nome estável, automaticamente observável. - Compatibilidade com tools e backends atuais. O executor delega para
backend.cancelar_vas_single,backend.vas_strategicebackend.pro_ratavia adapter. Nada do que funciona hoje precisa ser reescrito. - Migração progressiva por cobertura de intenção. Intenções cobertas pelo runtime usam o caminho novo; intenções não cobertas caem no
RunLoopExecutoratual viafallback_to_orchestrator, recebendo o estado serializado como contexto. - Versionamento de estado desde o dia um. Qualquer mudança breaking no schema do estado incrementa versão e dispara migração no carregamento da sessão.
- Exposição textual do estado para o LLM. O
ConversationStateexpõeto_prompt_context()como ponto único de tradução do estado em texto natural injetável no prompt. Filtra campos de telemetria e formata o que importa para decisão do LLM.
Governança anti-inflação da camada
Para impedir que a Policy e o state repliquem o problema do prompt do orquestrador (acumulação de regras e flags por incidente), aplicamos seis políticas. Estas políticas valem desde o primeiro PR.
1. Toda regra cita o incidente que cobre
Cada if na PolicyEngine.decide() deve ter comentário com identificador do incidente ou caso de uso. Regras sem motivação concreta não são mergeadas.
2. Regras devem ser disjuntas
Se duas regras casam ao mesmo tempo no mesmo estado, é sinal de problema. Teste obrigatório: para cada par de regras, escrever caso que prove que não há estado em que ambas casariam com decisões diferentes.
3. Regra com mais de 3 condições é sinal de erro
Se você se vê escrevendo if intent.kind == "X" and state.Y and not state.Z and state.W < 3, pare. Pelo menos uma condição deveria ter sido decidida em outro componente antes de chegar na Policy. A Policy compõe sinais, não classifica.
4. Em dúvida, fallback
A Policy não cobre tudo. Casos ambíguos, raros, ou que exigem compreensão de linguagem natural devolvem fallback_to_orchestrator. O LLM continua disponível com o state como contexto auxiliar.
5. State é dado de domínio, não acumulador de flags
Cada novo campo no ConversationState exige justificativa estrutural: quem produz, quem consome, qual conceito de domínio representa, por que não cabe em estrutura existente. Se serve apenas a uma regra da Policy, provavelmente é flag e está no lugar errado.
6. Métricas de saúde da camada
| Métrica | Como medir | Alvo em estado estacionário |
|---|---|---|
Regras na PolicyEngine.decide() |
grep return ConversationDecision |
menor que 15 |
Linhas do policy_engine.py |
wc -l | menor que 300 |
Campos do ConversationState |
contar campos da dataclass | menor que 20 |
Valores de IntentKind |
len do Literal | menor que 15 |
| Regras adicionadas no último mês | git log | menor que 3 |
Taxa de fallback_to_orchestrator |
telemetria | não zero (zero indica overfitting) |
Métricas crescendo sem justificativa estrutural levantam discussão em sync semanal.
7. ADR para regras após a 10ª
A Policy nasce com 7 regras. Crescer até 10 é evolução natural. A partir da 11ª regra, cada nova exige documento de uma página justificando incidente que motivou, por que não cabe em outro componente, qual classe inteira de casos cobre e como será removida se ficar obsoleta.
Estrutura de arquivos
agent/conversation/
├── __init__.py
├── spec.md # este documento
├── state.py # ConversationState, enums, value objects
├── mutations.py # 15 hooks nomeados
├── intent_extractor.py # IntentExtractor, ExtractedIntent, IntentKind
├── invoice_resolver.py # InvoiceResolver
├── action_queue.py # ActionQueueBuilder, ACTION_ORDER
├── confirmation_manager.py # ConfirmationManager com 3 guards
├── policy_engine.py # PolicyEngine, ConversationDecision
├── action_executor.py # ActionQueueExecutor, ToolAdapter
├── finalization_machine.py # FinalizationMachine
├── script_catalog.py # ScriptCatalog
├── response_composer.py # ResponseComposer
├── serialization.py # serialize/deserialize/migrate
├── runtime.py # ConversationRuntime (fachada)
├── scripts/ # falas canônicas em YAML
│ ├── juros_e_multas.yaml
│ ├── encerramento_resolvido.yaml
│ ├── encerramento_nao_resolvido.yaml
│ ├── devolucao_antes_cancelamento.yaml
│ ├── recusa_ressarcimento_dobro.yaml
│ ├── item_not_found.yaml
│ └── README.md
└── tests/
├── __init__.py
├── conftest.py
├── test_state.py
├── test_mutations.py
├── test_intent_extractor.py
├── test_invoice_resolver.py
├── test_action_queue.py
├── test_confirmation_manager.py
├── test_policy_engine.py
├── test_action_executor.py
├── test_finalization_machine.py
├── test_script_catalog.py
├── test_response_composer.py
├── test_serialization.py
└── test_runtime_end_to_end.py
Convenções de código
- Python 3.13. Sintaxe moderna (union types com
|,Self,Literal). - Sem dependência circular.
state.pyé base. Outros módulos importam destate.py.mutations.pyimportaintent_extractorapenas viaTYPE_CHECKING. - Tipos fechados. Enums e
Literal. Evitardict[str, Any]em contratos públicos. - Frozen quando aplicável. Value objects (
PendingConfirmation,ResolvedInvoiceItem,ActionStep,ResolvedAction) sãofrozen=True. Estado mutável apenas emConversationStateeActionQueue. - Mutação só por hook. Componentes que precisam mutar
ConversationStateimportam e chamam funções demutations.py. Nunca escrevem direto no campo. Vale parastate.statustambém. - Sem dependência de LangChain ou LangGraph. Permite testes unitários sem mock pesado.
- Sem efeito colateral em funções puras quando aplicável.
PolicyEngine.decide,InvoiceResolver.resolve_items,ActionQueueBuilder.buildsão puras. OIntentExtractor.extracté híbrido: caminho rápido é puro, caminho LLM faz IO via Protocol injetado. A interface Protocol garante que testes da camada não dependem de cliente concreto. - Logging via callbacks recebidos por injeção. Componentes não importam
langfusediretamente. - Falhar rápido.
ValueErrorem entradas inválidas. Não engolir exceção silenciosamente.
state.py
Define a estrutura de estado canônico do turno e todos os tipos auxiliares.
Constantes
SCHEMA_VERSION: int = 1
Enums
class ConversationStatus(str, Enum):
OPEN = "open"
AWAITING_CONFIRMATION = "awaiting_confirmation"
EXECUTING_ACTION = "executing_action"
AWAITING_WORKFLOW_INPUT = "awaiting_workflow_input"
FINALIZING = "finalizing"
CLOSED = "closed"
class Channel(str, Enum):
VOICE = "voice"
TEXT = "text"
class ToolKind(str, Enum):
INFO = "info"
EXPLANATION = "explanation"
ACTION = "action"
META = "meta"
class ToolOutcome(str, Enum):
SUCCESS = "success"
NO_MATCH = "no_match"
FAILURE = "failure"
PARTIAL = "partial"
Value objects (frozen)
@dataclass(frozen=True)
class ResolvedInvoiceItem:
canonical_name: str
tool_category: Literal["cancelar_vas_avulso", "vas_estrategico", "pro_rata"]
item_type: Literal["avulso", "estrategico", "bundle", "plano"]
msisdn: str
value: Decimal | None
section: str
raw_entry: dict[str, Any]
@dataclass(frozen=True)
class ActionStep:
tool: Literal["cancelar_vas_avulso", "pro_rata", "vas_estrategico"]
subject: str
items: list[dict[str, Any]]
@dataclass(frozen=True)
class PendingConfirmation:
id: str
authorized_queue: "ActionQueue"
asked_turn_index: int
prompt_text: str
@dataclass(frozen=True)
class ResolvedAction:
tool: str
subject: str
tool_kind: ToolKind
outcome: ToolOutcome
result_summary: dict[str, Any]
ActionQueue (mutável com cursor)
@dataclass
class ActionQueue:
steps: list[ActionStep] = field(default_factory=list)
cursor: int = 0
def current(self) -> ActionStep | None:
if self.cursor >= len(self.steps):
return None
return self.steps[self.cursor]
def remaining_after_current(self) -> list[ActionStep]:
return self.steps[self.cursor + 1:]
def advance(self) -> None:
self.cursor += 1
def is_empty(self) -> bool:
return len(self.steps) == 0
def is_exhausted(self) -> bool:
return self.cursor >= len(self.steps)
ConversationState (mutável, via hooks)
@dataclass
class ConversationState:
schema_version: int = SCHEMA_VERSION
session_id: str = ""
turn_index: int = 0
channel: Channel = Channel.VOICE
status: ConversationStatus = ConversationStatus.OPEN
pending_confirmation: PendingConfirmation | None = None
action_queue: ActionQueue = field(default_factory=ActionQueue)
last_completed_action: ResolvedAction | None = None
last_invoice_answer: Literal["SIM", "NAO"] | None = None
client_requested_end: bool = False
off_context_count: int = 0
informative_answer_delivered: bool = False
last_finalization_reason: str | None = None
decision_trace: list[dict[str, Any]] = field(default_factory=list)
def to_prompt_context(self) -> str:
"""Representação textual filtrada do estado para injeção no prompt do LLM.
Inclui campos que orientam decisão do LLM (status, fila, confirmação,
última ação, resposta de fatura, sinais de turno). Exclui campos
puramente de telemetria (decision_trace, last_finalization_reason).
"""
lines = [
f"Turno atual: {self.turn_index}",
f"Canal: {self.channel.value}",
f"Status: {self.status.value}",
]
if self.pending_confirmation is not None:
lines.append(
f"Aguardando confirmação do cliente sobre: "
f"{self.pending_confirmation.prompt_text}"
)
if not self.action_queue.is_empty():
current = self.action_queue.current()
remaining = self.action_queue.remaining_after_current()
if current is not None:
lines.append(
f"Próxima ação na fila: {current.tool} para {current.subject}"
)
if remaining:
pending_subjects = ", ".join(s.subject for s in remaining)
lines.append(f"Pendentes depois: {pending_subjects}")
if self.last_completed_action is not None:
la = self.last_completed_action
lines.append(
f"Última ação executada: {la.tool} para {la.subject}, "
f"resultado: {la.outcome.value}"
)
if self.last_invoice_answer is not None:
lines.append(
f"Resposta do cliente sobre a fatura: {self.last_invoice_answer}"
)
if self.client_requested_end:
lines.append("Cliente pediu encerramento.")
if self.off_context_count > 0:
lines.append(
f"Cliente está há {self.off_context_count} turno(s) fora de contexto."
)
return "\n".join(lines)
Snapshot da fatura para anáfora plural (as-built)
Campos novos em ConversationState para suportar referência anafórica plural a itens listados turnos atrás:
invoice_snapshot: tuple[ResolvedInvoiceItem, ...] = ()
invoice_snapshot_source_turn: int = 0
invoice_snapshot_source_msisdn: str = ""
Motivação (incidentes CY0004 runs 211739/211914). Quando o T2 do agente lista 7 itens via invoice_explanation e o cliente faz negação genérica plural no T5 ("mas eu não contratei nenhum deles"), o IntentClassifierContext.conversation_history tem janela curta e o classificador perde referência aos 5 avulsos listados em T2 (anáfora curta). O InvoiceResolver ignora menções não-extraídas. A ActionQueue sai subdimensionada (1 step de 2 itens em vez de 7) e o composer emite confirmação errada.
Lifecycle. O snapshot é populado uma vez no turno em que invoice_explanation retorna com sucesso, via hook on_invoice_snapshot_populated(state, snapshot, msisdn, source_turn) em mutations.py. O conteúdo vem de InvoiceResolver.build_snapshot(invoice_detail, msisdn), que reusa _iter_candidates + _build_item para classificar todos os itens das 4 seções suportadas (mesma lógica de resolve). Invalida via on_invoice_snapshot_invalidated quando (a) on_msisdn_changed dispara ou (b) on_action_completed registra SUCCESS de cancelar_vas_avulso. Re-populate é lazy: próxima invoice_explanation que rodar repopula. Em conversas multi-cancel longas, a nova chamada repopula naturalmente.
Consumo. O snapshot é exposto ao classificador via IntentClassifierContext.invoice_snapshot_items (string renderizada filtrada pelo state.msisdn ativo, agrupada por seção, máximo 20 linhas, para cobrir família multi-msisdn). O prompt do classificador tem uma regra curta: quando a fala do cliente contém negação genérica plural (nenhum deles, nada disso, todos esses, esses todos, nenhum desses) e o snapshot está populado, expandir mentioned_items para todos os itens listados. Sem snapshot, o classificador degrada gracefully (recebe string vazia ou nenhum).
Serialização. Bumpar CONVERSATION_STATE_SCHEMA_VERSION v6→v7 em serialization.py. Helpers _ser_snapshot e _deser_snapshot cuidam de Decimal → str e raw_entry jsonable. Sessões v6 antigas restauram com invoice_snapshot = () (graceful default). Sessões SSE são curtas (3-5 turnos); deploy em janela de baixo tráfego.
Reversibilidade. Comentar _populate_invoice_snapshot_if_needed em uma linha do runtime desliga o FIX 1 inteiro mantendo o resto. Kill-switch CONVERSATION_RUNTIME_ENABLED=false reverte o runtime inteiro.
Critérios de aceite
- Todos os enums com valores em snake_case minúsculos.
- Todas as dataclasses frozen têm
__hash__automático. ActionQueuetem testes paracurrent,remaining_after_current,advance,is_empty,is_exhaustedcobrindo fila vazia, fila com um item, fila exaurida.ConversationStatetem valores default seguros para criação sem argumentos.to_prompt_context()tem testes cobrindo estado vazio, com confirmação pendente, com fila e última ação, e exclusão explícita de campos de telemetria.invoice_snapshottem testes de default vazio, round-trip de serialização v7, restore de sessão v6 produz snapshot vazio, e invalidação via hooks (mudança de msisdn, SUCCESS de cancelamento).
mutations.py
Catálogo de 15 hooks nomeados. Cada hook recebe state e muta in-place. Componentes externos importam e chamam.
Hooks de ciclo de turno
def on_turn_started(state: ConversationState, intent: "ExtractedIntent") -> None:
state.turn_index += 1
state.client_requested_end = intent.kind == "human_or_end"
Hooks de confirmação
def on_confirmation_requested(
state: ConversationState,
confirmation: PendingConfirmation,
) -> None:
state.pending_confirmation = confirmation
state.status = ConversationStatus.AWAITING_CONFIRMATION
def on_confirmation_consumed(state: ConversationState, queue: ActionQueue) -> None:
state.action_queue = queue
state.pending_confirmation = None
state.status = ConversationStatus.EXECUTING_ACTION
Hooks de ação
def on_action_started(state: ConversationState, step: ActionStep) -> None:
# Observabilidade apenas; sem mutação.
pass
def on_action_completed(state: ConversationState, action: ResolvedAction) -> None:
state.last_completed_action = action
state.action_queue.advance()
Hooks de workflow
def on_workflow_paused(
state: ConversationState, pending: PendingWorkflowResume
) -> None:
state.status = ConversationStatus.AWAITING_WORKFLOW_INPUT
state.pending_workflow = pending # dados p/ o runtime retomar no próximo turno
def on_workflow_resumed(state: ConversationState) -> None:
state.status = ConversationStatus.EXECUTING_ACTION
state.pending_workflow = None # pendência consumida
Resume é dono do runtime. Quando um step pausa (aguardando_resposta), o
executor guarda em state.pending_workflow (tool, execution_id,
expected_input_key, allowed_values, question, normalize) e o estado fica
AWAITING_WORKFLOW_INPUT. No próximo turno, try_handle intercepta ANTES de
classificar: interpreta a resposta (ValueInterpreter) e retoma o workflow pelo
MESMO trilho conversation.action.execute (ActionQueueExecutor.resume_current,
reinvocando o backend com o execution_id). O runtime NÃO seta mais
agent._pending_tool_resume — esse caminho (langchain try_resume) fica
exclusivo das tools pausáveis do orquestrador (ex.: invoice_explanation).
Hooks de resposta de domínio
def on_invoice_answer(
state: ConversationState,
answer: Literal["SIM", "NAO"],
) -> None:
state.last_invoice_answer = answer
def on_informative_answered(state: ConversationState) -> None:
state.informative_answer_delivered = True
Hooks de off-context
def on_off_context_detected(state: ConversationState) -> None:
state.off_context_count += 1
def on_in_context_message(state: ConversationState) -> None:
state.off_context_count = 0
Hooks de finalização
def on_finalization_emitted(state: ConversationState, reason: str) -> None:
state.last_finalization_reason = reason
state.status = ConversationStatus.CLOSED
Hooks de transição (status puro)
Estes três hooks existem para que mutações de state.status feitas pela FinalizationMachine passem pelo padrão de hook, mantendo observabilidade uniforme.
def on_next_action_requested(state: ConversationState) -> None:
state.status = ConversationStatus.EXECUTING_ACTION
def on_finalize_recommended(state: ConversationState) -> None:
state.status = ConversationStatus.FINALIZING
def on_turn_continued(state: ConversationState) -> None:
state.status = ConversationStatus.OPEN
Contrato
- Cada hook recebe
statecomo primeiro parâmetro e o muta in-place. - Cada hook emite span filho em Langfuse com nome
conversation.hook.<nome>contendo o diff de campos antes e depois (a emissão é responsabilidade de decorator externo). - Hooks são chamados de exatamente um lugar no código. Chamada múltipla é violação de contrato.
- Hooks nunca chamam outros hooks. Composição acontece no chamador.
- Mutação de
state.statusacontece exclusivamente via hooks. Nenhum componente fora demutations.pyescreve emstate.statusdiretamente.
Critérios de aceite
- Cada hook tem teste unitário próprio cobrindo o caso normal e ao menos uma borda.
- Hooks não chamam outros hooks. Composição acontece no chamador.
- Hooks não emitem logs nem disparam eventos diretamente.
intent_extractor.py
Transforma user_text em ExtractedIntent usando estratégia híbrida: caminho rápido com regras determinísticas para casos triviais (afirmações curtas, negações curtas, frases canônicas de encerramento) e caminho LLM com schema estruturado fechado para o restante. O IntentKind é taxonomia fechada e estável; o LLM produz dado tipado dentro dessa taxonomia, nunca decisão final.
Por que LLM
Classificação de intenção em diálogo real envolve nuance que regras determinísticas não capturam bem. Frases como "vou querer entender essa cobrança que apareceu agora" são pedido de explicação que regex de palavras-chave erra. "Quero ver se dá pra tirar esse Netflix" é ação que regex de "cancelar" não pega. O LLM resolve esses casos sem fazer o extractor virar repositório de regras.
O caminho rápido continua existindo para latência: classificar "sim" ou "tchau" em milissegundos, sem chamada de rede. Mensagens longas ou ambíguas vão para o LLM.
Tipo de saída
IntentKind = Literal[
"start",
"invoice_overview",
"broad_invoice_complaint",
"conceptual_question",
"value_question",
"action_request",
"confirmation_response",
"human_or_end",
"off_context",
"unknown",
]
@dataclass(frozen=True)
class ExtractedIntent:
kind: IntentKind
requested_action: bool = False
action_verbs: list[str] = field(default_factory=list)
mentioned_items: list[str] = field(default_factory=list)
affirmation: bool = False
negation: bool = False
rag_query: list[str] = field(default_factory=list)
raw_text: str = ""
source: Literal["rules", "llm", "fallback"] = "rules"
O campo source registra qual caminho produziu o intent. Usado em telemetria para medir cobertura de cada caminho e detectar regressões.
O campo rag_query carrega as consultas canônicas do RAG para conceptual_question — uma entrada por serviço/conceito perguntado (os "pontos de dúvida"), no formato "<serviço ou conceito> o que é". Fala simples vira lista de um item ("tem youtube também" → ["youtube o que é"]); fala composta vira várias ("tamboro e tim fashion o que é" → ["tamboro o que é", "tim fashion o que é"]). Vazio nas demais intenções. A PolicyEngine repassa essa lista como as queries da decisão rag; o backend (buscar_informacao_rag) busca cada uma em paralelo. Na ausência de rag_query, cai na fala literal (user_text). Isso revoga a decisão anterior de usar sempre a fala crua, que produzia recuperação ruim na base vetorial (ex.: "um tal de tamboro" não casava documento algum). Ver policy_engine.py.
Contexto de classificação
A classificação por LLM precisa de contexto mínimo do turno para resolver ambiguidades comuns (ex.: "ok" depois de uma pergunta de confirmação é confirmation_response, "tudo certo" depois de explicação de fatura também é). Esse contexto é derivado do ConversationState pelo IntentExtractor antes de chamar o classificador.
@dataclass(frozen=True)
class IntentClassifierContext:
"""Contexto mínimo passado ao classificador LLM.
Derivado do ConversationState pelo IntentExtractor. Frozen: o
classificador não pode mutar. Enxuto: apenas sinais que ajudam
classificar a mensagem atual, nunca o estado inteiro.
"""
has_pending_confirmation: bool
pending_confirmation_prompt: str | None
last_tool_name: str | None
last_tool_kind: ToolKind | None
last_invoice_answer: Literal["SIM", "NAO"] | None
turn_index: int
Princípios para o contexto não inflar.
- Apenas sinais derivados do
ConversationState. Nada novo entra no contexto sem antes existir no estado canônico. - Apenas o que o classificador usaria para classificar
IntentKind. Histórico completo de mensagens, dados de fatura, identificação do cliente: tudo isso fica fora. - Frozen. O classificador não pode mutar, só ler.
Contrato do classificador LLM
A dependência de LLM é injetada via Protocol, não importa cliente concreto. Isso permite testar a camada sem mock pesado de LangChain.
from typing import Protocol
class IntentClassifierLLM(Protocol):
"""Classificador de intenção via LLM com schema fechado.
Implementações concretas usam langchain-oci, langchain-groq ou outro
cliente que suporte structured output. O Protocol é a única dependência
direta do IntentExtractor.
"""
def classify(
self,
user_text: str,
*,
context: IntentClassifierContext,
) -> dict[str, Any]:
"""Retorna dict aderente ao INTENT_CLASSIFICATION_SCHEMA.
Em caso de falha (timeout, parse error, schema inválido), levanta
IntentClassifierError. O IntentExtractor trata como fallback.
O `context` é frozen. A implementação concreta usa para enriquecer
o prompt do classificador.
"""
...
class IntentClassifierError(Exception):
"""Erro do classificador LLM (timeout, parse, schema)."""
pass
Schema fechado do output do LLM
O classificador LLM deve produzir saída aderente a este schema. Implementações concretas usam structured output ou tool calling para garantir aderência.
INTENT_CLASSIFICATION_SCHEMA = {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"invoice_overview",
"broad_invoice_complaint",
"conceptual_question",
"value_question",
"action_request",
"confirmation_response",
"human_or_end",
"off_context",
"unknown",
],
},
"requested_action": {"type": "boolean"},
"action_verbs": {"type": "array", "items": {"type": "string"}},
"mentioned_items": {"type": "array", "items": {"type": "string"}},
"affirmation": {"type": "boolean"},
"negation": {"type": "boolean"},
"rag_query": {"type": "array", "items": {"type": "string"}},
},
"required": ["kind"],
"additionalProperties": False,
}
O classificador concreto (CapabilityIntentClassifier + prompt conversation_intent_classifier, v5) é instruído a preencher rag_query somente em conceptual_question, com uma query canônica por conceito (split de falas compostas), removendo ruído conversacional ("tem", "um tal de", "também") e resolvendo o serviço/conceito pela fala ou pelo histórico. O schema é validado post-hoc (o prompt instrui o JSON; extract() mapeia via _str_or_list(payload.get("rag_query")), tolerando lista ou string única), então o campo novo propaga sem mudança de infraestrutura.
O valor start do IntentKind é sentinela do sistema (primeiro turno antes de qualquer mensagem do cliente) e não aparece no schema do LLM. Não cabe ao LLM classificar como start.
API
class IntentExtractor:
AFFIRMATIONS = {"sim", "isso", "isso mesmo", "pode seguir", "correto", "confirmo"}
NEGATIONS = {"nao", "não", "negativo", "nem", "não quero"}
END_PHRASES = ("so isso", "só isso", "obrigado", "obrigada", "tchau")
def __init__(self, llm_classifier: IntentClassifierLLM) -> None: ...
def extract(
self,
user_text: str,
*,
state: ConversationState,
) -> ExtractedIntent: ...
def _try_rules(self, normalized: str, user_text: str) -> ExtractedIntent | None: ...
def _build_classifier_context(
self,
state: ConversationState,
) -> IntentClassifierContext: ...
def _classify_with_llm(
self,
user_text: str,
context: IntentClassifierContext,
) -> ExtractedIntent: ...
O IntentExtractor recebe o ConversationState como input de leitura. Não muta. Usa o estado apenas para construir o IntentClassifierContext que enriquece o classificador LLM.
Algoritmo de extração
A função extract segue três etapas em ordem.
def extract(self, user_text: str, *, state: ConversationState) -> ExtractedIntent:
normalized = user_text.strip().lower()
# 1. Caminho rápido: regras determinísticas para casos óbvios.
# Não depende de contexto. Resolve casos canônicos exatos.
fast_path = self._try_rules(normalized, user_text)
if fast_path is not None:
return fast_path
# 2. Caminho LLM: classificação estruturada com contexto do turno.
context = self._build_classifier_context(state)
try:
return self._classify_with_llm(user_text, context)
except IntentClassifierError:
# 3. Fallback: intent unknown, sinaliza source="fallback" para telemetria.
return ExtractedIntent(kind="unknown", raw_text=user_text, source="fallback")
def _build_classifier_context(
self,
state: ConversationState,
) -> IntentClassifierContext:
return IntentClassifierContext(
has_pending_confirmation=state.pending_confirmation is not None,
pending_confirmation_prompt=(
state.pending_confirmation.prompt_text
if state.pending_confirmation is not None
else None
),
last_tool_name=(
state.last_completed_action.tool
if state.last_completed_action is not None
else None
),
last_tool_kind=(
state.last_completed_action.tool_kind
if state.last_completed_action is not None
else None
),
last_invoice_answer=state.last_invoice_answer,
turn_index=state.turn_index,
)
Regras do caminho rápido
A ordem importa. Encerramento ganha de afirmação e negação.
- Encerramento: se
normalizedcontém algumaEND_PHRASE, retornaExtractedIntent(kind="human_or_end", source="rules"). - Afirmação canônica: se
normalized in AFFIRMATIONS(match exato), retornaExtractedIntent(kind="confirmation_response", affirmation=True, source="rules"). - Negação canônica: se
normalized in NEGATIONS(match exato), retornaExtractedIntent(kind="confirmation_response", negation=True, source="rules"). - Qualquer outro caso: retorna
None, indicando que o caminho rápido não classificou e o LLM deve ser chamado.
O caminho rápido não tenta classificar action_request, invoice_overview ou conceptual_question por palavras-chave. Esses casos vão direto para o LLM. Isso é mudança em relação ao desenho anterior baseado em palavras-chave.
Caminho LLM
def _classify_with_llm(
self,
user_text: str,
context: IntentClassifierContext,
) -> ExtractedIntent:
result = self._llm_classifier.classify(user_text, context=context)
# result é dict aderente a INTENT_CLASSIFICATION_SCHEMA, garantido pelo
# structured output do cliente concreto. Aqui apenas mapeia para o dataclass.
return ExtractedIntent(
kind=result["kind"],
requested_action=result.get("requested_action", False),
action_verbs=result.get("action_verbs", []),
mentioned_items=result.get("mentioned_items", []),
affirmation=result.get("affirmation", False),
negation=result.get("negation", False),
rag_query=_str_or_list(result.get("rag_query")), # lista; tolera string única
raw_text=user_text,
source="llm",
)
A implementação concreta do IntentClassifierLLM recebe o context e usa para enriquecer o prompt do classificador. Casos típicos:
- Se
context.has_pending_confirmationeuser_texté afirmação não-canônica (ex.: "ok", "tá bom"), classifica comoconfirmation_responsecomaffirmation=True. - Se
context.last_tool_name == "invoice_explanation"euser_texté resposta breve ("certo", "entendi"), classifica comoconfirmation_response. - Se
context.turn_index == 1euser_texté cumprimento ("oi", "bom dia"), classifica comostartouunknownconforme prompt.
Tratamento de falha
Quando _classify_with_llm levanta IntentClassifierError, o IntentExtractor devolve ExtractedIntent(kind="unknown", source="fallback"). A Policy trata unknown como fallback_to_orchestrator, levando o turno para o RunLoopExecutor. O LLM principal do orquestrador então responde com o to_prompt_context() injetado.
Esse fallback é importante: a falha do classificador não derruba o turno; apenas degrada para o comportamento atual.
Prompt e implementação concreta do classificador
A implementação concreta do IntentClassifierLLM vive fora do pacote agent/conversation/. Ela escolhe modelo (sugestão: pequeno e rápido como Llama via Groq ou Cohere via OCI), prompt de classificação e configuração de structured output. O pacote agent/conversation/ apenas consome o Protocol.
Sugestão de prompt para a implementação concreta (referência, não normativo):
Você é um classificador de intenção em diálogo de atendimento sobre fatura
de telecom. Receba o contexto do turno e a mensagem do cliente, e classifique
em uma das categorias do schema. Extraia também os itens mencionados (nomes
de serviços ou produtos) quando relevante.
[CONTEXTO DO TURNO]
Turno: {turn_index}
Há confirmação pendente: {has_pending_confirmation}
Pergunta de confirmação anterior: {pending_confirmation_prompt or "nenhuma"}
Última ação executada: {last_tool_name or "nenhuma"} ({last_tool_kind or "n/a"})
Última resposta de fatura: {last_invoice_answer or "nenhuma"}
[MENSAGEM DO CLIENTE]
"{user_text}"
Categorias:
- invoice_overview: cliente quer entender a fatura em geral
- broad_invoice_complaint: reclamação genérica sobre fatura
- conceptual_question: pergunta sobre conceito ou termo
- value_question: pergunta específica sobre valor
- action_request: pedido de ação (cancelar, contestar, ajustar)
- confirmation_response: resposta a uma pergunta de confirmação
- human_or_end: cliente quer encerrar ou falar com humano
- off_context: mensagem fora do escopo de fatura
- unknown: não foi possível classificar
Regras de uso do contexto:
- Se há confirmação pendente e a mensagem é breve afirmativa
(ex.: "ok", "tá bom", "pode"), classifique como confirmation_response
com affirmation=true.
- Se a última ação foi invoice_explanation e a mensagem é breve afirmativa
("certo", "entendi", "tudo claro"), classifique como confirmation_response.
- Se a mensagem é breve negativa em qualquer contexto ("não", "negativo"),
classifique como confirmation_response com negation=true.
Devolva apenas o JSON aderente ao schema.
Observabilidade
A implementação concreta do classificador é responsável por emitir spans Langfuse com:
intent.classifier.latency_msintent.classifier.modelintent.classifier.prompt_tokensintent.classifier.completion_tokensintent.classifier.kind(resultado)intent.classifier.error(quando aplicável)
O IntentExtractor em si emite apenas:
intent.source(rules, llm, fallback)intent.kind(resultado final)
Critérios de aceite
- Testes do caminho rápido cobrem
human_or_end,confirmation_responseafirmativa,confirmation_responsenegativa e precedência ("cancelar, obrigada"resolve parahuman_or_end). O caminho rápido não recebestatee produzsource="rules". - Testes do caminho LLM usam fake
IntentClassifierLLMque retorna dict do schema. Cobrem cada um dos 8 kinds que o LLM pode produzir (excluindostart, que é sentinela). - Teste de fallback: fake do classifier que levanta
IntentClassifierErrorproduzExtractedIntent(kind="unknown", source="fallback"). - Teste do campo
source: regra produzsource="rules", LLM produzsource="llm", falha produzsource="fallback". - O
_classify_with_llmé mockável viaIntentClassifierLLMProtocol. Sem mock de LangChain ou de cliente HTTP. - Pelo menos um teste valida que mensagem longa que não casa com regras vai para o LLM e não cai em
unknownno caminho rápido. - Testes do
_build_classifier_contextcobrem: state vazio (todos os campos opcionais como None), state compending_confirmationpreenchido, state comlast_completed_actionpreenchido, state comlast_invoice_answerdefinido. - Teste de leitura sem mutação: chamar
extract(text, state=state)não muta nenhum campo dostate(compararstateantes e depois deve ser igual). - Teste de uso do contexto pelo classifier: fake
IntentClassifierLLMregistra ocontextrecebido e o teste valida que os campos foram corretamente derivados dostate.
Considerações de latência
O caminho rápido resolve em microssegundos (string operations). O caminho LLM adiciona latência de rede mais inferência. Estimativas dependem do modelo escolhido na implementação concreta, mas o pacote agent/conversation/ não toma decisão sobre isso.
Quando a latência do LLM degrada o turno, a alternativa é a implementação concreta caching layer ou modelo menor. Não é responsabilidade do IntentExtractor no pacote.
invoice_resolver.py
Cruza mentioned_items com invoice_detail e devolve itens canônicos. Substitui várias regras do prompt sobre msisdn, classe e seção.
Constantes
STRATEGIC_NAMES = {
"apple music", "deezer", "disney", "hbo", "netflix",
"youtube", "youtube premium", "paramount", "looke",
}
API
class InvoiceResolver:
def resolve_items(
self,
mentioned_items: list[str],
invoice_detail: dict[str, Any],
) -> list[ResolvedInvoiceItem]: ...
def _resolve_section(
self,
mentioned_items: list[str],
entries: list[dict[str, Any]],
*,
section: str,
default_tool: str,
default_type: str,
) -> list[ResolvedInvoiceItem]: ...
def _classify(self, desc: str, section: str, default_type: str) -> str: ...
@staticmethod
def _was_mentioned(desc: str, mentioned_items: list[str]) -> bool: ...
@staticmethod
def _parse_money(value: Any) -> Decimal | None: ...
@staticmethod
def _dedupe_exact_matches(
items: list[ResolvedInvoiceItem],
) -> list[ResolvedInvoiceItem]: ...
Seções tratadas
Para cada linha (msisdn) do invoice_detail, exceto Fatura Resumo:
| Seção do invoice_detail | default_tool | default_type |
|---|---|---|
SVA Detalhe Total |
cancelar_vas_avulso |
avulso |
Itens Eventuais |
cancelar_vas_avulso |
avulso |
Serviços Contratados de Terceiros |
vas_estrategico |
estrategico |
Serviços Bundle Inclusos |
vas_estrategico |
bundle |
Regra de _classify
def _classify(self, desc: str, section: str, default_type: str) -> str:
desc_lower = desc.lower()
if section == "Serviços Bundle Inclusos":
return "bundle"
if any(name in desc_lower for name in STRATEGIC_NAMES):
return "estrategico"
return default_type
Regra de tool_category
tool_category = "vas_estrategico" if item_type in {"bundle", "estrategico"} else default_tool
Regra de _was_mentioned
Match case-insensitive bidirecional: desc.lower() in mention.lower() ou mention.lower() in desc.lower().
Regra de msisdn
Vem da entrada cujo desc bate com o item mencionado, nunca da linha titular por default.
Matching híbrido + ambiguidade (as-built)
Além do resolve_items (determinístico, back-compat — devolve todos os matches), o resolver expõe resolve(...) -> ResolutionOutcome, usado pelo runtime, com consciência de ambiguidade e match tolerante a grafia:
- Matcher LLM injetado (espelha
IntentExtractor).InvoiceResolver(*, matcher: ItemMatcherLLM | None = None).ItemMatcherLLMé Protocol (match(mention, candidates, *, callbacks=None) -> list[str]); o concretoCapabilityItemMatcher(móduloitem_matcher.py, capabilityconversation_item_matcher) fica fora do resolver para mantê-lo livre de langchain. Sem matcher, o resolver é puro/determinístico (todos os contratos deresolve_itemsinalterados). - Acionado só no miss. Para cada menção: tenta o determinístico (substring); se 0 matches e houver matcher, manda os
desccandidatos ao LLM (grafia: "aia"→"Aya"). Falha do matcher é engolida → menção sem match. Happy-path não chama LLM. - Modelo dedicado (20b). O
core.pyinjeta no matcher um LLM fixado na variante OCI 20b (ConversationRuntime(..., matcher_llm=_build_item_matcher_llm(config))) — tarefa simples, não precisa do modelo do orquestrador. Reusa o principal quando não-OCI ou já-20b; falha na construção degrada para o principal. A geração aparece no Langfuse comoconversation_item_matcher(callbacks repassados viaresolve(..., callbacks=...)). ItemResolution(mention, matches)comis_ambiguous= >1 match distinto por(canonical_name, msisdn)(nome diferente ou mesma desc em linhas diferentes).ResolutionOutcome(resolved, ambiguous, not_found):resolvedé o flat dedupado das menções não-ambíguas (alimenta oActionQueueBuilder);ambiguousvira diretiva ao orquestrador;not_foundsão menções sem match.- Roteamento. Qualquer menção ambígua → Policy regra 3.5 →
fallback_to_orchestratorcomreason="ambiguous_item_match". O runtime devolve a diretiva curta emRuntimeResult.orchestrator_directive(viaResponseComposer.disambiguation_directive); ocore.pya anexa à fala do cliente (build_messages(llm_directive=...), marcador###...###) que o orquestrador vê — não ao system prompt e não ao histórico salvo (mesmo padrão doregen_flag).
Critérios de aceite
- Fixture com
invoice_detailreal cobrindo as quatro seções. - Teste explícito para item estratégico (ex.: Netflix).
- Teste explícito para item avulso em SVA Detalhe Total.
- Teste explícito para item bundle.
- Teste de dedupe com mesmo item em duas linhas.
- Teste de msisdn correto vindo da entrada exata.
resolve(): menção ambígua (2 Ayas mesmo nome/linhas diferentes; 2 nomes distintos) cai emambiguous; menção sem match emnot_found; matcher acionado só no miss; matcher devolvendo 2+ → ambíguo;ItemMatcherErrordegrada para sem-match.
action_queue.py
Constrói a ActionQueue a partir dos itens resolvidos, com ordem fixa.
Constantes
ACTION_ORDER: dict[str, int] = {
"cancelar_vas_avulso": 0,
"pro_rata": 1,
"vas_estrategico": 2,
}
API
class ActionQueueBuilder:
def build(self, items: list[ResolvedInvoiceItem]) -> ActionQueue: ...
def _subject_for(self, items: list[ResolvedInvoiceItem]) -> str: ...
Algoritmo
- Agrupa
itemsportool_category. - Para cada grupo em ordem de
ACTION_ORDER, cria umActionStepcomtool=tool_category,subject=join(canonical_names),items=[asdict(item) for item in group]. - Devolve
ActionQueue(steps=steps, cursor=0).
Critérios de aceite
- Teste do exemplo VOD + HBO Max: queue tem dois steps em ordem
cancelar_vas_avulso, vas_estrategico. - Teste de fila com só uma tool: queue tem um step.
- Teste de fila vazia: queue retornada com
is_empty() == True. - Teste de múltiplos itens da mesma tool: agrupados em um único step.
confirmation_manager.py
Cria e consome confirmações com três guards.
API
class ConfirmationManager:
AFFIRMATIONS = {"sim", "isso", "isso mesmo", "pode seguir", "correto", "confirmo"}
def create_confirmation(
self,
state: ConversationState,
action_queue: ActionQueue,
*,
prompt_text: str,
turn_index: int,
) -> PendingConfirmation: ...
def consume_if_confirmed(
self,
state: ConversationState,
user_text: str,
*,
current_turn_index: int,
) -> ActionQueue | None: ...
Três guards de consume_if_confirmed
A ordem de checagem importa. Se qualquer guard falha, devolve None.
- Guard de status.
state.statusdeve serAWAITING_CONFIRMATION. Caso contrário, há outro evento em andamento. - Guard de turno.
current_turn_indexdeve ser exatamentestate.pending_confirmation.asked_turn_index + 1. Garante que o "sim" é da resposta direta à pergunta. - Guard de afirmação.
user_text.strip().lower()deve estar emAFFIRMATIONS. Negações e textos ambíguos não consomem.
Mutação via hooks
create_confirmation chama on_confirmation_requested(state, confirmation).
consume_if_confirmed, quando todos os guards passam, chama on_confirmation_consumed(state, queue).
Critérios de aceite
- Teste "sim válido no turno correto" devolve a queue autorizada.
- Teste "sim no turno errado" devolve
None. - Teste "sim com status OPEN" devolve
None. - Teste "negação no turno correto" devolve
Nonee não muta o estado. - Teste "afirmação parcial não-canônica" (ex.: "claro") devolve
None.
policy_engine.py
Função pura que decide o próximo evento.
Tipo de saída
@dataclass(frozen=True)
class ConversationDecision:
kind: Literal[
"answer",
"ask_confirmation",
"execute_action_queue",
"call_informative_tool",
"redirect_off_context",
"finalize",
"fallback_to_orchestrator",
]
payload: dict[str, Any]
API
class PolicyEngine:
def decide(
self,
state: ConversationState,
*,
intent: ExtractedIntent,
resolved_items: list[ResolvedInvoiceItem],
action_queue: ActionQueue,
) -> ConversationDecision: ...
Sete regras em ordem de avaliação
A primeira regra que casa devolve a decisão. Sem fallthrough. Cada regra deve ter comentário citando o caso de uso ou incidente que cobre.
- Confirmação consumida.
state.pending_confirmation is not None and intent.kind == "confirmation_response" and intent.affirmation→execute_action_queuecompayload={"queue": state.action_queue}. - Encerramento solicitado.
intent.kind == "human_or_end"→finalizecompayload={"status": "nao_resolvido"}. - Pergunta conceitual.
intent.kind == "conceptual_question"→call_informative_toolcompayload={"tool": "buscar_informacao"}. - Visão geral da fatura.
intent.kind == "invoice_overview"→call_informative_toolcompayload={"tool": "invoice_explanation"}. - Ação com items resolvidos.
intent.requested_action and resolved_items→ask_confirmationcompayload={"action_queue": action_queue}. - Ação sem items.
intent.requested_action and not resolved_items→answercompayload={"message_key": "item_not_found"}. - Off-context terminal.
state.off_context_count >= 3→finalizecompayload={"status": "outros_assuntos"}.
Default: fallback_to_orchestrator com payload={"reason": "uncovered_intent"}.
Notas de implementação (as-built)
A implementação atual usa um ConversationDecision(action=..., ...) enxuto (sem payload genérico) e diverge da nomenclatura aspiracional acima em alguns pontos:
- Regra 3 (pergunta conceitual). Decisão
action="rag"(RAG inline, nãocall_informative_tool). As consultas sãointent.rag_query(lista, uma por conceito) quando presentes, senão a fala literal:refined = tuple(q.strip() for q in intent.rag_query if q.strip()); queries = refined or ((user_text.strip(),) if user_text.strip() else ()). As N queries são buscadas em paralelo pelo backend. Se nada sobra,queriesvai vazia e o runtime (_handle_rag) devolvefallback_to_orchestratorcom motivorag_no_query— o RAG nunca é chamado com lista vazia. - Regra 3.5 (ação com alvo ambíguo). Novo:
decide(..., has_ambiguity: bool). Quandokind == action_requestehas_ambiguity(o runtime pré-computa viaInvoiceResolver.resolve(...).ambiguous— uma menção casou com 2+ itens distintos), decisãoaction="fallback_to_orchestrator",reason="ambiguous_item_match". Avaliada antes da regra 5 (precedência: basta uma menção ambígua). O runtime devolve a diretiva curta emRuntimeResult.orchestrator_directive(candidatos = nome + final da linha, viaResponseComposer.disambiguation_directive); ocore.pya anexa à fala do cliente (RunLoopExecutor.execute(llm_directive=...)→build_messages), entre marcadores###...###, sem tocar o system prompt nem o histórico salvo (mesmo padrão doregen_flag). O orquestrador pergunta a qual item o cliente se refere. Governança §7: a regra refina a famíliaaction_request(incidente "dois-ayas"), não introduz conceito de domínio novo; mantém-se abaixo do alvo de <15return ConversationDecision. - Regra 5 (ação com items). Decisão
action="ask_confirmation"carregando aaction_queuepré-computada (resolver + builder rodam no runtime antes dodecide). - Regra 6 (ação sem items). Implementada como decisão
action="item_not_found"(nãoanswer/message_key). O runtime compõe a fala viaResponseComposer.item_not_found(intent.mentioned_items)e devolvehandled=True; o status segueOPENpara o cliente reformular. Espelha o comportamento canônico do prompt do orquestrador ("diga ao cliente que não encontrou esse serviço na fatura"). - Regra 1.5b (negação órfã). Novo, espelho da Regra 1.5 (afirmação órfã):
kind == confirmation_response and intent.negation and pending_confirmation is None→fallback_to_orchestrator,reason="negation_without_pending". Cobre o caso em que o cliente nega sem haver pergunta de confirmação ativa. O orquestrador interpreta livremente sem directive estruturada. Governança §7: regra simétrica à 1.5, sem novo conceito de domínio. - Regra 1.6 (negação com pending sem verbo de ação). Novo (incidentes CY0006 runs 230207/230336/230436).
kind == confirmation_response and intent.negation and pending_confirmation is not None and not intent.action_verbs→fallback_to_orchestrator,reason="confirmation_response_negation_pending". O runtime monta uma directive estruturada viaResponseComposer.negation_with_pending_directive(pending)e anexa àHumanMessagepelo mesmo mecanismo da Regra 3.5 (disambiguation_directive). A directive lista os itens da fila pendente e enumera dois caminhos para o orquestrador: (A) caminho de ação (só se cliente trouxer verbo de ação, atualmente TODO, fica para sprint futura), (B) caminho informativo default (explicar origens possíveis da cobrança como teste gratuito, engano em canal digital, aceite incidental, com pergunta aberta sem oferta proativa de cancelamento). O guardnot intent.action_verbsdeixa fallthrough natural para Regra 5 quando o cliente diz "não, cancela esses" (verbo presente). Governança §7: regra refina a famíliaconfirmation_responsepara um sub-caso específico (negação informativa) que viola anti-proatividade quando não roteada, sem introduzir conceito de domínio novo. Precedência: depois de Regras 1 e 1.5, antes da Regra 5.
Critérios de aceite
- Um teste por regra (sete testes mínimo).
- Teste de precedência: regra 1 ganha de regra 5 quando ambas casam.
- Teste de precedência: regra 2 ganha de regra 6 quando ambas casam.
- Teste da regra 3.5:
has_ambiguity=Trueemaction_request→ambiguous_item_match, com precedência sobreask_confirmation. - Teste da regra 1.5b: negação órfã (
negation=Truesempending_confirmation) devolvenegation_without_pending. - Teste da regra 1.6 default:
confirmation_response + negation + pending sem action_verbsdevolveconfirmation_response_negation_pendingcom directive composta pelo composer. - Teste da regra 1.6 fallthrough:
confirmation_response + negation + pending COM action_verbscai para Regra 5 (ask_confirmationcom nova fila). - Teste de precedência: Regra 1 (
affirmation) ganha de Regra 1.6 (negation) quando ambas têm pending. - Teste do default: state vazio com intent.kind="unknown" devolve
fallback_to_orchestrator. - Teste de pureza: chamar
decideduas vezes com mesmo input devolve decisões iguais. - Cada regra tem comentário citando o caso de uso ou incidente.
action_executor.py
Executa o próximo step da fila usando os backends existentes.
API
class ToolAdapter:
def __init__(self, backend) -> None: ...
def invoke(
self,
step: ActionStep,
*,
remaining: list[dict[str, Any]],
) -> dict[str, Any]: ...
class ActionQueueExecutor:
def __init__(self, tool_adapter: ToolAdapter) -> None: ...
def execute_next(self, state: ConversationState) -> dict[str, Any]: ...
Mapeamento de tool para backend (ToolAdapter.invoke)
if step.tool == "cancelar_vas_avulso":
return self._backend.cancelar_vas_single(
items=self._to_cancel_items(step.items),
workflow_input={"remaining": remaining},
)
if step.tool == "vas_estrategico":
return self._backend.vas_strategic(
items=self._to_strategic_items(step.items),
workflow_input={"remaining": remaining},
)
if step.tool == "pro_rata":
return self._backend.pro_rata(
**self._to_pro_rata_args(step.items, remaining),
)
raise ValueError(f"Unsupported action tool: {step.tool}")
Fluxo de execute_next
- Obter
step = state.action_queue.current(). SeNone, devolve{"success": True, "queue_empty": True}. - Chamar
on_action_started(state, step). - Calcular
remaining = [{"tool": s.tool, "subject": s.subject} for s in state.action_queue.remaining_after_current()]. - Invocar
result = self._tool_adapter.invoke(step, remaining=remaining). - Classificar
outcomea partir doresult(ver tabela abaixo). - Construir
ResolvedActione chamaron_action_completed(state, resolved_action). - Devolver
result.
Classificação de ToolOutcome
| Condição no result | Outcome |
|---|---|
result.get("success") == True e sem erros |
SUCCESS |
result.get("not_found") == True |
NO_MATCH |
result.get("error") is not None |
FAILURE |
| Sucesso parcial em items múltiplos | PARTIAL |
Critérios de aceite
- Teste por tool com mock de backend.
- Teste de fila vazia devolve
queue_empty=True. - Teste de
remainingcorretamente derivado da fila (não do histórico). - Teste de avance do cursor após sucesso (via
on_action_completed). - Teste de não-avance em falha (cursor permanece para retry futuro).
finalization_machine.py
Decide o que fazer depois do retorno de uma ação. Usa exclusivamente hooks para mutar state.status.
API
class FinalizationMachine:
def after_tool_result(
self,
state: ConversationState,
tool_result: dict[str, Any],
) -> Literal["ask_next_action", "wait_workflow_input", "finalize", "continue"]: ...
def build_finalize_payload(self, status: str, summary: str) -> dict[str, Any]: ...
def mark_closed(self, state: ConversationState) -> None: ...
Quatro caminhos de after_tool_result
A ordem importa. Primeira condição que casa devolve.
def after_tool_result(self, state, tool_result):
if state.action_queue.current() is not None:
on_next_action_requested(state)
return "ask_next_action"
if tool_result.get("aguardando_resposta") is True:
on_workflow_paused(state)
return "wait_workflow_input"
if tool_result.get("recomenda_finalizacao") is True:
on_finalize_recommended(state)
return "finalize"
on_turn_continued(state)
return "continue"
Critérios de aceite
- Teste por caminho (quatro testes).
- Teste de precedência: fila não-vazia ganha de
aguardando_resposta. - Teste de
build_finalize_payloadcom status canônicos (resolvido,nao_resolvido,outros_assuntos). - Teste de que
state.statusé mutado apenas via hooks (grep "state.status =" no arquivo retorna zero).
script_catalog.py
Carrega scripts em YAML na inicialização e oferece seleção por trigger.
API
@dataclass(frozen=True)
class ScriptParameter:
name: str
format: Literal["currency_brl", "date_long", "protocol_digits", "msisdn_digits", "raw"]
@dataclass(frozen=True)
class Script:
id: str
trigger: str
text: str
parameters: list[ScriptParameter]
channel: dict[Channel, str]
class ScriptCatalog:
def __init__(self, scripts_dir: Path) -> None: ...
def select_by_trigger(self, trigger: str) -> Script | None: ...
def render(
self,
script_id: str,
params: dict[str, Any] | None = None,
channel: Channel = Channel.VOICE,
) -> str: ...
Formato YAML esperado
id: <identificador único>
trigger: <chave semântica única>
text: |
<texto canônico, pode conter {placeholders}>
parameters:
- name: <nome>
format: currency_brl | date_long | protocol_digits | msisdn_digits | raw
channel:
voice: same | <texto alternativo>
text: same | <texto alternativo>
Carregamento
- Na construção, varre
scripts_dirpor arquivos.yaml. - Valida cada arquivo contra o schema.
- Indexa por
ide portrigger. Triggers duplicados causamValueError. - Não recarrega em runtime; recarga é responsabilidade do operador via restart.
Render
- Resolve o script por
script_idou levantaKeyError. - Substitui placeholders no texto usando
str.format(**rendered_params). rendered_paramsé construído chamando o channel adapter para cada parâmetro conformeformat.- Se canal é
voicee o script define alternativa de voz não-same, usa a alternativa.
Critérios de aceite
- Teste de carga com diretório válido.
- Teste de erro com YAML malformado.
- Teste de erro com trigger duplicado.
- Teste de render com parâmetros simples.
- Teste de render com parâmetro
currency_brlem ambos os canais. - Teste de erro com parâmetro citado em
textmas ausente emparameters.
response_composer.py
Compõe falas dinâmicas e proxia falas estáticas do catálogo.
API
class ResponseComposer:
def __init__(self, catalog: ScriptCatalog, channel_adapter) -> None: ...
def confirmation(self, action_queue: ActionQueue) -> str: ...
def next_action_transition(self, next_subject: str) -> str: ...
def item_not_found(self, service_name: str) -> str: ...
def from_catalog(
self,
script_id: str,
params: dict[str, Any] | None = None,
) -> str: ...
Composição de confirmation (as-built)
Nota as-built. A assinatura implementada é
ResponseComposer(*, channel: Channel = Channel.VOICE)(oScriptCatalog/channel_adapterda API aspiracional ainda não existem). Apenasconfirmation()eitem_not_found(mentioned_items: list[str])estão implementados;next_action_transition/from_catalogentram com oScriptCatalog.
confirmation() decide o molde da frase pelo conjunto de categorias presentes na fila (uma categoria → template de intenção única; duas ou mais → template de múltiplas intenções), adaptando dinamicamente singular/plural, números de linha iguais ou diferentes (agrupando itens por msisdn) e múltiplas intenções. Duas categorias em escopo (pro rata fora por ora):
- avulso (
cancelar_vas_avulso): verbo SEMPRE "cancelar", item-a-item com valor e número próprio. Item:"{service}, no valor de {R$ valor}, vinculado ao número final {final}"(juntos por", ").- Intenção única:
"Vamos seguir com o cancelamento {do serviço|dos serviços} {corpo} e providenciar a retirada dos valores da fatura. Você confirma?"
- Intenção única:
- estratégico/bundle (
vas_estrategico): verbo SEMPRE "falar sobre", SEM valor, agrupado por número. 1º grupo:"{nomes} {vinculado|vinculados} ao número final {final}"; grupos seguintes:", e também {do|dos serviços} {nomes} ... ao número final {final}".- Intenção única:
"Entendi que você deseja falar sobre {o serviço|os serviços} {corpo}. Correto?"
- Intenção única:
- pro rata (fora de escopo): fragmento estático mínimo, só para não quebrar filas legadas.
Múltiplas intenções (2+ categorias, ordem fixa avulso → estratégico → pro rata): "Entendi que você deseja " + fragmento_lead + ", " + fragmentos_seguintes + ". Correto?". O avulso (sempre 1º por ACTION_ORDER) lidera sem "e também"; os demais trazem "e também" embutido. O verbo é fixo por bloco e nunca cruza (avulso "cancelar"; estratégico "falar sobre", mesmo que o cliente tenha pedido cancelamento).
{service}/{nomes} vêm dos items da step (service no avulso, name no estratégico) — necessário para agrupar por número. Moeda fica canônica (R$ 1.234,50; a vocalização para voz ocorre no boundary SSE); os 4 dígitos finais são vocalizados aqui no canal VOICE (legado preservado). Fila vazia → ""; tool desconhecida → ValueError.
Composição de item_not_found
item_not_found(mentioned_items) — fala determinística para action_request cujo resolver não casou item na fatura (Policy regra 6). Com nomes: "Não localizei {o serviço|os serviços} {nomes} na sua fatura. Pode confirmar o nome do serviço, por favor?"; lista vazia (cliente disse "cancela isso" sem nomear): fala genérica equivalente.
Regra de uso
ScriptCatalog para falas estáticas regulatórias (palavra por palavra como definido). ResponseComposer para composições dinâmicas baseadas em fila e items. Ambos passam pelo channel adapter no fim.
Critérios de aceite
- Teste do exemplo VOD + HBO Max: gera frase com dois trechos juntados.
- Teste de step único.
- Teste de step com múltiplos items na mesma tool.
- Teste de
from_catalogproxia para o catálogo corretamente. - Teste de
item_not_foundproduz mensagem com nome do serviço.
serialization.py
Serialização JSON-safe e versionamento do estado.
API
def serialize_state(state: ConversationState) -> dict[str, Any]: ...
def deserialize_state(data: dict[str, Any]) -> ConversationState: ...
def migrate_state(persisted: dict[str, Any]) -> ConversationState: ...
Regras de serialização
- Enums viram strings (
.value). Decimalvira string.Noneem campos opcionais é omitido da saída (não aparece a chave).ActionQueueserializastepsecursor.PendingConfirmation.authorized_queueé serializada de forma aninhada.
Versionamento
- Se
persisted.get("schema_version") == SCHEMA_VERSION, deserialização direta. - Se for versão anterior conhecida, aplica migrações em cadeia.
- Se versão desconhecida ou ausente, devolve estado novo com
session_idpreservado.
Critérios de aceite
- Round-trip: state → serialize → deserialize → state, igualdade preservada.
- Round-trip com
ActionQueueno meio da execução (cursor > 0). - Round-trip com
PendingConfirmationpreenchida. - Migração de dict V0 sintético para V1 produz estado válido.
- Versão desconhecida produz estado novo sem exceção.
runtime.py
Fachada que orquestra todos os componentes.
API
@dataclass
class RuntimeResult:
handled: bool
payload: dict[str, Any] | None = None
fallback_reason: str | None = None
@classmethod
def handled_text(cls, text: str) -> "RuntimeResult":
return cls(handled=True, payload={"content": text})
@classmethod
def fallback(cls, reason: str) -> "RuntimeResult":
return cls(handled=False, fallback_reason=reason)
class ConversationRuntime:
def __init__(
self,
*,
intent_extractor: IntentExtractor,
invoice_resolver: InvoiceResolver,
queue_builder: ActionQueueBuilder,
confirmation_manager: ConfirmationManager,
policy_engine: PolicyEngine,
action_executor: ActionQueueExecutor,
finalization_machine: FinalizationMachine,
response_composer: ResponseComposer,
state_store: "ConversationStateStore",
) -> None: ...
def try_handle(
self,
*,
user_text: str,
context: dict[str, Any],
history: list,
callbacks: list,
) -> RuntimeResult: ...
def export_state(self) -> dict[str, Any]: ...
def restore_state(self, snapshot: dict[str, Any]) -> None: ...
Fluxo de try_handle
1. state = state_store.load(context)
2. intent = intent_extractor.extract(user_text, state=state)
# IntentExtractor lê o state (read-only) para montar IntentClassifierContext.
# Lê ANTES de on_turn_started: turn_index, pending_confirmation,
# last_completed_action e last_invoice_answer continuam refletindo o
# turno anterior, que é exatamente o que o classificador precisa.
3. on_turn_started(state, intent)
4. confirmed_queue = confirmation_manager.consume_if_confirmed(
state, user_text, current_turn_index=context["turn_index"])
if confirmed_queue:
result = action_executor.execute_next(state)
transition = finalization_machine.after_tool_result(state, result)
return _handle_post_action(state, result, transition)
5. invoice_detail = context.get("invoice_detail") or {}
resolved_items = invoice_resolver.resolve_items(intent.mentioned_items, invoice_detail)
action_queue = queue_builder.build(resolved_items) if resolved_items else ActionQueue()
6. decision = policy_engine.decide(
state, intent=intent, resolved_items=resolved_items, action_queue=action_queue)
state.decision_trace.append(_trace(intent, resolved_items, decision))
7. roteamento por decision.action (as-built):
- "ask_confirmation": gera texto via composer, cria pending_confirmation, retorna handled_text
- "execute_action_queue": executa next e processa transition
- "rag": chama `buscar_informacao(queries)`; query vazia ou sem resposta → fallback (`rag_no_query`/`rag_no_answer`)
- "item_not_found": compõe `ResponseComposer.item_not_found(intent.mentioned_items)`, retorna handled_text (status segue OPEN)
- "finalize": on_finalization_emitted, retorna handled_text com script de encerramento
- "fallback_to_orchestrator": injeta to_prompt_context no contexto e retorna fallback
(As ações `call_informative_tool`/`redirect_off_context`/`answer` da API aspiracional ainda não foram ligadas; quando entrarem, somam-se aqui.)
8. state_store.save(context, state)
Ordem importante. O extract no passo 2 é chamado antes do on_turn_started no passo 3 para que o classificador veja o estado do turno anterior intacto. Mudar essa ordem fragiliza casos de confirmação ("ok" depois de pergunta), porque o contexto perderia a referência exata.
Injeção do contexto no fallback
No caminho fallback_to_orchestrator, o runtime injeta o resumo textual do estado no contexto antes de salvar:
if decision.kind == "fallback_to_orchestrator":
context["conversation_state_summary"] = state.to_prompt_context()
self._state_store.save(context, state)
return RuntimeResult.fallback(reason=decision.payload["reason"])
O RunLoopExecutor atual passa a receber esse resumo e pode incluí-lo no prompt como seção de contexto auxiliar.
Critérios de aceite
- Cinco cenários end-to-end cobertos em
test_runtime_end_to_end.py:- Cliente pede cancelar dois VAS (VOD e HBO Max), confirma, sistema executa em ordem.
- Cliente questiona invoice_explanation, responde SIM, sistema finaliza como resolvido.
- Cliente diz "não, só isso obrigada", sistema finaliza como nao_resolvido.
- Cliente sai do contexto três turnos, sistema finaliza como outros_assuntos.
- Cliente solicita ressarcimento em dobro, sistema responde com script canônico e aguarda confirmação.
- Teste de intent não coberta devolve
fallback_to_orchestratore injetaconversation_state_summaryno contexto. - Teste de export_state / restore_state preserva o estado completo entre turnos.
Testes
Estrutura
agent/conversation/tests/
├── conftest.py # fixtures comuns
├── test_state.py
├── test_mutations.py
├── test_intent_extractor.py
├── test_invoice_resolver.py
├── test_action_queue.py
├── test_confirmation_manager.py
├── test_policy_engine.py
├── test_action_executor.py
├── test_finalization_machine.py
├── test_script_catalog.py
├── test_response_composer.py
├── test_serialization.py
└── test_runtime_end_to_end.py
Convenções
- Pytest como framework.
- Sem mocks de LangChain ou LangGraph nos testes da camada.
- Fixtures de
invoice_detailem JSON dentro detests/fixtures/. - Cobertura mínima por arquivo: 90%.
- Nomes de teste no formato
test_<comportamento>_<contexto>(ex.:test_decide_finalize_when_client_requests_end).
Cenários obrigatórios
Os cinco cenários end-to-end são parte da definição de pronto do runtime.py. Sem eles, o runtime não vai para o bloco 2.
Integração com o agente existente
A integração com LangChainWorkflowAgent.run acontece em tarefas separadas do bloco 2 do plano de execução. O pacote agent/conversation/ por si não toca nada fora dele. Quando vier a integração, o ponto de chamada será:
# agent/infra/langchain/agent/core.py (LangChainWorkflowAgent.run)
resumed = self._resume_handler.try_resume(...)
if resumed and resumed.early_payload is not None:
return self._apply_output_guardrail(resumed.early_payload, ...)
runtime_result = self._conversation_runtime.try_handle(
user_text=user_text,
context=self.get_context_snapshot(),
history=self._conversation_history,
callbacks=callbacks,
)
if runtime_result.handled:
return self._apply_output_guardrail(runtime_result.payload, ...)
# Fallback: o context já tem conversation_state_summary injetado
return self._run_loop.execute(...)
Esse ponto de integração fica atrás do kill switch CONVERSATION_RUNTIME_ENABLED, padrão desligado em produção. A persistência do estado da camada entra em export_session_state e restore_session_state do core.py como campo conversation_runtime.
Critérios para integração
- Sem alteração de contrato externo (endpoints, payloads, eventos emitidos).
- Reversibilidade por flag.
- Replay sobre amostra de traces reais antes da ativação efetiva.
- O
RunLoopExecutorlêcontext["conversation_state_summary"]quando presente e injeta no prompt como seção de contexto auxiliar.
Detalhes operacionais dessa etapa de integração não pertencem a este spec. Este documento cobre apenas o que vive dentro do pacote agent/conversation/.