mirror of
https://github.com/hoshikawa2/first_contas.git
synced 2026-07-09 18:24:20 +00:00
1582 lines
76 KiB
Markdown
1582 lines
76 KiB
Markdown
# 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_resolver` → `action_queue` → `confirmation_manager` → `action_executor` → `finalization_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_question` → **RAG 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_composer` → `script_catalog` + `scripts/*.yaml`.
|
|
|
|
## Sumário
|
|
|
|
- [Visão geral](#visão-geral)
|
|
- [Princípios fundamentais](#princípios-fundamentais)
|
|
- [Governança anti-inflação da camada](#governança-anti-inflação-da-camada)
|
|
- [Estrutura de arquivos](#estrutura-de-arquivos)
|
|
- [Convenções de código](#convenções-de-código)
|
|
- [state.py](#statepy)
|
|
- [mutations.py](#mutationspy)
|
|
- [intent_extractor.py](#intent_extractorpy)
|
|
- [invoice_resolver.py](#invoice_resolverpy)
|
|
- [action_queue.py](#action_queuepy)
|
|
- [confirmation_manager.py](#confirmation_managerpy)
|
|
- [policy_engine.py](#policy_enginepy)
|
|
- [action_executor.py](#action_executorpy)
|
|
- [finalization_machine.py](#finalization_machinepy)
|
|
- [script_catalog.py](#script_catalogpy)
|
|
- [response_composer.py](#response_composerpy)
|
|
- [serialization.py](#serializationpy)
|
|
- [runtime.py](#runtimepy)
|
|
- [Testes](#testes)
|
|
- [Integração com o agente existente](#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_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
|
|
|
|
```python
|
|
SCHEMA_VERSION: int = 1
|
|
```
|
|
|
|
### Enums
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
@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)
|
|
|
|
```python
|
|
@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)
|
|
|
|
```python
|
|
@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:
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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](#policy_enginepy).
|
|
|
|
### 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.
|
|
|
|
```python
|
|
@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.
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
STRATEGIC_NAMES = {
|
|
"apple music", "deezer", "disney", "hbo", "netflix",
|
|
"youtube", "youtube premium", "paramount", "looke",
|
|
}
|
|
```
|
|
|
|
### API
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
ACTION_ORDER: dict[str, int] = {
|
|
"cancelar_vas_avulso": 0,
|
|
"pro_rata": 1,
|
|
"vas_estrategico": 2,
|
|
}
|
|
```
|
|
|
|
### API
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
@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
|
|
|
|
```python
|
|
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.affirmation` → `execute_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_items` → `ask_confirmation` com `payload={"action_queue": action_queue}`.
|
|
6. **Ação sem items.** `intent.requested_action and not resolved_items` → `answer` com `payload={"message_key": "item_not_found"}`.
|
|
7. **Off-context terminal.** `state.off_context_count >= 3` → `finalize` 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 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 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_request` → `ambiguous_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
|
|
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
@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
|
|
|
|
```yaml
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
@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:
|
|
|
|
```python
|
|
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á:
|
|
|
|
```python
|
|
# 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 `RunLoopExecutor` lê `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/`.
|