Files
first_contas/legacy_reference/docs/conversation_spec.md
2026-06-16 20:54:49 -03:00

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.py e serialization.py estão implementados; a cadeia de ação da Fase 2 (invoice_resolveraction_queueconfirmation_manageraction_executorfinalization_machine) está ligada no runtime.py, e o response_composer cobre confirmation() (templates dinâmicos) e item_not_found(). script_catalog + scripts/*.yaml seguem como roadmap.

Completo:

  • intent_extractor.py: IntentKind (taxonomia de 10), ExtractedIntent, IntentClassifierContext, IntentClassifierLLM (Protocol), INTENT_CLASSIFICATION_SCHEMA, IntentExtractor (fast-path AFFIRMATIONS/NEGATIONS/END_PHRASES) e CapabilityIntentClassifier (capability conversation_intent_classifier + fallback inline).
  • state.py: enums (ConversationStatus/Channel/ToolKind/ToolOutcome), value objects (ResolvedInvoiceItem/ActionStep/PendingConfirmation/ResolvedAction), ActionQueue e ConversationState (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 PolicyEngine trata deterministicamente: confirmation_response + pending → execute_action_queue; action_request com menção ambígua (2+ matches distintos) → fallback_to_orchestrator com reason="ambiguous_item_match" (runtime anexa uma diretiva curta à fala do cliente via RuntimeResult.orchestrator_directive, não ao system prompt); action_request com items → ask_confirmation, sem items → item_not_found; conceptual_question → RAG inline (consulta = rag_query refinada, 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 (com conversation_state_summary injetado).

Divergências duráveis (decididas):

  • conceptual_questionRAG inline no runtime (decisão de produto), em vez de call_informative_tool delegando 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 devolve rag_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.classify recebe callbacks opcional (observabilidade do span do classificador).
  • turn_index é incrementado pela própria camada em on_turn_started (ela é dona do contador), não espelhado do _turn_index do core.
  • serialization reseta 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 + regras ask_confirmation/execute_action_queue/call_informative_tool/redirect_off_context da Policy) → finalization_machine + response_composerscript_catalog + scripts/*.yaml.

Sumário

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_orchestrator por intenção: a PolicyEngine cobre um conjunto fechado de intenções. Intenções não cobertas devolvem fallback_to_orchestrator e o RunLoopExecutor atual assume o turno com acesso ao estado serializado via to_prompt_context().
  • Variável de ambiente CONVERSATION_RUNTIME_ENABLED como kill switch global. Quando desligada, todo turno vai direto para o RunLoopExecutor, 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Compatibilidade com tools e backends atuais. O executor delega para backend.cancelar_vas_single, backend.vas_strategic e backend.pro_rata via adapter. Nada do que funciona hoje precisa ser reescrito.
  6. Migração progressiva por cobertura de intenção. Intenções cobertas pelo runtime usam o caminho novo; intenções não cobertas caem no RunLoopExecutor atual via fallback_to_orchestrator, recebendo o estado serializado como contexto.
  7. 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.
  8. Exposição textual do estado para o LLM. O ConversationState expõe to_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

  1. Python 3.13. Sintaxe moderna (union types com |, Self, Literal).
  2. Sem dependência circular. state.py é base. Outros módulos importam de state.py. mutations.py importa intent_extractor apenas via TYPE_CHECKING.
  3. Tipos fechados. Enums e Literal. Evitar dict[str, Any] em contratos públicos.
  4. Frozen quando aplicável. Value objects (PendingConfirmation, ResolvedInvoiceItem, ActionStep, ResolvedAction) são frozen=True. Estado mutável apenas em ConversationState e ActionQueue.
  5. Mutação só por hook. Componentes que precisam mutar ConversationState importam e chamam funções de mutations.py. Nunca escrevem direto no campo. Vale para state.status também.
  6. Sem dependência de LangChain ou LangGraph. Permite testes unitários sem mock pesado.
  7. Sem efeito colateral em funções puras quando aplicável. PolicyEngine.decide, InvoiceResolver.resolve_items, ActionQueueBuilder.build são puras. O IntentExtractor.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.
  8. Logging via callbacks recebidos por injeção. Componentes não importam langfuse diretamente.
  9. Falhar rápido. ValueError em 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.
  • ActionQueue tem testes para current, remaining_after_current, advance, is_empty, is_exhausted cobrindo fila vazia, fila com um item, fila exaurida.
  • ConversationState tem 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_snapshot tem 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 state como 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.status acontece exclusivamente via hooks. Nenhum componente fora de mutations.py escreve em state.status diretamente.

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.

  1. Encerramento: se normalized contém alguma END_PHRASE, retorna ExtractedIntent(kind="human_or_end", source="rules").
  2. Afirmação canônica: se normalized in AFFIRMATIONS (match exato), retorna ExtractedIntent(kind="confirmation_response", affirmation=True, source="rules").
  3. Negação canônica: se normalized in NEGATIONS (match exato), retorna ExtractedIntent(kind="confirmation_response", negation=True, source="rules").
  4. 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_confirmation e user_text é afirmação não-canônica (ex.: "ok", "tá bom"), classifica como confirmation_response com affirmation=True.
  • Se context.last_tool_name == "invoice_explanation" e user_text é resposta breve ("certo", "entendi"), classifica como confirmation_response.
  • Se context.turn_index == 1 e user_text é cumprimento ("oi", "bom dia"), classifica como start ou unknown conforme 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_ms
  • intent.classifier.model
  • intent.classifier.prompt_tokens
  • intent.classifier.completion_tokens
  • intent.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_response afirmativa, confirmation_response negativa e precedência ("cancelar, obrigada" resolve para human_or_end). O caminho rápido não recebe state e produz source="rules".
  • Testes do caminho LLM usam fake IntentClassifierLLM que retorna dict do schema. Cobrem cada um dos 8 kinds que o LLM pode produzir (excluindo start, que é sentinela).
  • Teste de fallback: fake do classifier que levanta IntentClassifierError produz ExtractedIntent(kind="unknown", source="fallback").
  • Teste do campo source: regra produz source="rules", LLM produz source="llm", falha produz source="fallback".
  • O _classify_with_llm é mockável via IntentClassifierLLM Protocol. 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 unknown no caminho rápido.
  • Testes do _build_classifier_context cobrem: state vazio (todos os campos opcionais como None), state com pending_confirmation preenchido, state com last_completed_action preenchido, state com last_invoice_answer definido.
  • Teste de leitura sem mutação: chamar extract(text, state=state) não muta nenhum campo do state (comparar state antes e depois deve ser igual).
  • Teste de uso do contexto pelo classifier: fake IntentClassifierLLM registra o context recebido e o teste valida que os campos foram corretamente derivados do state.

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 concreto CapabilityItemMatcher (módulo item_matcher.py, capability conversation_item_matcher) fica fora do resolver para mantê-lo livre de langchain. Sem matcher, o resolver é puro/determinístico (todos os contratos de resolve_items inalterados).
  • Acionado só no miss. Para cada menção: tenta o determinístico (substring); se 0 matches e houver matcher, manda os desc candidatos ao LLM (grafia: "aia"→"Aya"). Falha do matcher é engolida → menção sem match. Happy-path não chama LLM.
  • Modelo dedicado (20b). O core.py injeta 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 como conversation_item_matcher (callbacks repassados via resolve(..., callbacks=...)).
  • ItemResolution(mention, matches) com is_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 o ActionQueueBuilder); ambiguous vira diretiva ao orquestrador; not_found são menções sem match.
  • Roteamento. Qualquer menção ambígua → Policy regra 3.5 → fallback_to_orchestrator com reason="ambiguous_item_match". O runtime devolve a diretiva curta em RuntimeResult.orchestrator_directive (via ResponseComposer.disambiguation_directive); o core.py a 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 do regen_flag).

Critérios de aceite

  • Fixture com invoice_detail real 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 em ambiguous; menção sem match em not_found; matcher acionado só no miss; matcher devolvendo 2+ → ambíguo; ItemMatcherError degrada 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

  1. Agrupa items por tool_category.
  2. Para cada grupo em ordem de ACTION_ORDER, cria um ActionStep com tool=tool_category, subject=join(canonical_names), items=[asdict(item) for item in group].
  3. 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.

  1. Guard de status. state.status deve ser AWAITING_CONFIRMATION. Caso contrário, há outro evento em andamento.
  2. Guard de turno. current_turn_index deve ser exatamente state.pending_confirmation.asked_turn_index + 1. Garante que o "sim" é da resposta direta à pergunta.
  3. Guard de afirmação. user_text.strip().lower() deve estar em AFFIRMATIONS. 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 None e 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.

  1. Confirmação consumida. state.pending_confirmation is not None and intent.kind == "confirmation_response" and intent.affirmationexecute_action_queue com payload={"queue": state.action_queue}.
  2. Encerramento solicitado. intent.kind == "human_or_end"finalize com payload={"status": "nao_resolvido"}.
  3. Pergunta conceitual. intent.kind == "conceptual_question"call_informative_tool com payload={"tool": "buscar_informacao"}.
  4. Visão geral da fatura. intent.kind == "invoice_overview"call_informative_tool com payload={"tool": "invoice_explanation"}.
  5. Ação com items resolvidos. intent.requested_action and resolved_itemsask_confirmation com payload={"action_queue": action_queue}.
  6. Ação sem items. intent.requested_action and not resolved_itemsanswer com payload={"message_key": "item_not_found"}.
  7. Off-context terminal. state.off_context_count >= 3finalize com payload={"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ão call_informative_tool). As consultas são intent.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, queries vai vazia e o runtime (_handle_rag) devolve fallback_to_orchestrator com motivo rag_no_query — o RAG nunca é chamado com lista vazia.
  • Regra 3.5 (ação com alvo ambíguo). Novo: decide(..., has_ambiguity: bool). Quando kind == action_request e has_ambiguity (o runtime pré-computa via InvoiceResolver.resolve(...).ambiguous — uma menção casou com 2+ itens distintos), decisão action="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 em RuntimeResult.orchestrator_directive (candidatos = nome + final da linha, via ResponseComposer.disambiguation_directive); o core.py a 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 do regen_flag). O orquestrador pergunta a qual item o cliente se refere. Governança §7: a regra refina a família action_request (incidente "dois-ayas"), não introduz conceito de domínio novo; mantém-se abaixo do alvo de <15 return ConversationDecision.
  • Regra 5 (ação com items). Decisão action="ask_confirmation" carregando a action_queue pré-computada (resolver + builder rodam no runtime antes do decide).
  • Regra 6 (ação sem items). Implementada como decisão action="item_not_found" (não answer/message_key). O runtime compõe a fala via ResponseComposer.item_not_found(intent.mentioned_items) e devolve handled=True; o status segue OPEN para 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 Nonefallback_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_verbsfallback_to_orchestrator, reason="confirmation_response_negation_pending". O runtime monta uma directive estruturada via ResponseComposer.negation_with_pending_directive(pending) e anexa à HumanMessage pelo 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 guard not intent.action_verbs deixa fallthrough natural para Regra 5 quando o cliente diz "não, cancela esses" (verbo presente). Governança §7: regra refina a família confirmation_response para 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=True em action_requestambiguous_item_match, com precedência sobre ask_confirmation.
  • Teste da regra 1.5b: negação órfã (negation=True sem pending_confirmation) devolve negation_without_pending.
  • Teste da regra 1.6 default: confirmation_response + negation + pending sem action_verbs devolve confirmation_response_negation_pending com directive composta pelo composer.
  • Teste da regra 1.6 fallthrough: confirmation_response + negation + pending COM action_verbs cai para Regra 5 (ask_confirmation com 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 decide duas 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

  1. Obter step = state.action_queue.current(). Se None, devolve {"success": True, "queue_empty": True}.
  2. Chamar on_action_started(state, step).
  3. Calcular remaining = [{"tool": s.tool, "subject": s.subject} for s in state.action_queue.remaining_after_current()].
  4. Invocar result = self._tool_adapter.invoke(step, remaining=remaining).
  5. Classificar outcome a partir do result (ver tabela abaixo).
  6. Construir ResolvedAction e chamar on_action_completed(state, resolved_action).
  7. 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 remaining corretamente 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_payload com 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

  1. Na construção, varre scripts_dir por arquivos .yaml.
  2. Valida cada arquivo contra o schema.
  3. Indexa por id e por trigger. Triggers duplicados causam ValueError.
  4. Não recarrega em runtime; recarga é responsabilidade do operador via restart.

Render

  1. Resolve o script por script_id ou levanta KeyError.
  2. Substitui placeholders no texto usando str.format(**rendered_params).
  3. rendered_params é construído chamando o channel adapter para cada parâmetro conforme format.
  4. Se canal é voice e 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_brl em ambos os canais.
  • Teste de erro com parâmetro citado em text mas ausente em parameters.

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) (o ScriptCatalog/channel_adapter da API aspiracional ainda não existem). Apenas confirmation() e item_not_found(mentioned_items: list[str]) estão implementados; next_action_transition/from_catalog entram com o ScriptCatalog.

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?"
  • 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?"
  • 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_catalog proxia para o catálogo corretamente.
  • Teste de item_not_found produz 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).
  • Decimal vira string.
  • None em campos opcionais é omitido da saída (não aparece a chave).
  • ActionQueue serializa steps e cursor.
  • 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_id preservado.

Critérios de aceite

  • Round-trip: state → serialize → deserialize → state, igualdade preservada.
  • Round-trip com ActionQueue no meio da execução (cursor > 0).
  • Round-trip com PendingConfirmation preenchida.
  • 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:
    1. Cliente pede cancelar dois VAS (VOD e HBO Max), confirma, sistema executa em ordem.
    2. Cliente questiona invoice_explanation, responde SIM, sistema finaliza como resolvido.
    3. Cliente diz "não, só isso obrigada", sistema finaliza como nao_resolvido.
    4. Cliente sai do contexto três turnos, sistema finaliza como outros_assuntos.
    5. Cliente solicita ressarcimento em dobro, sistema responde com script canônico e aguarda confirmação.
  • Teste de intent não coberta devolve fallback_to_orchestrator e injeta conversation_state_summary no 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_detail em JSON dentro de tests/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 RunLoopExecutorcontext["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/.