Contrato de resposta: {JSON}Mensagem¶
Contrato de transporte dos agentes¶
Triagem, Qualificação e Coleta retornam uma string composta por um objeto JSON inicial e, após ele, o texto destinado ao cliente. O objeto contém obrigatoriamente s inteiro. O parser é parse_llm_response(raw) em src/core/application/parser.py; retorna ParsedLLMResponse(s, json_part, message).
{"s":1,"qualification_data":{"tipo_amostra":"amostra sintética"}}Quantas amostras serão analisadas?
O cliente recebe somente o texto posterior ao JSON, depois de format_ai_message_for_whatsapp(). O objeto alimenta dispatcher e persistência. Para s=98/s=99, o prompt exige terminar no fechamento do JSON; o dispatcher ignora mensagem desses códigos. O parser por si só aceita uma mensagem vazia e não conhece qual código a permite.
Algoritmo real¶
- Aplica
lstrip()para tolerar whitespace inicial do provider. - Exige
{como primeiro caractere restante. - Percorre contando chaves, respeitando strings, barras de escape e aspas escapadas.
- O primeiro
}que zera a profundidade delimita o JSON. - Faz
json.loads()somente desse trecho; o restante recebelstrip()e vira mensagem. - Exige objeto com
sdo tipo inteiro.boolé recusado explicitamente, mesmo sendo subtipo deintem Python.
Não há regex extraindo JSON de qualquer posição, nem parser tolerante a JSON inválido. A aceitação estrutural não valida serviço, segmento, campos obrigatórios ou código por agente; essas etapas pertencem a action_mapping.py.
Casos válidos e inválidos¶
| Resposta | Resultado do parser |
|---|---|
{"s":1}Olá. |
s=1, mensagem Olá. |
Espaços/quebra de linha antes de {"s":1} |
Aceito pelo lstrip, apesar da instrução mais restritiva do prompt |
{"s":99,"context_needed":"COMPANY_INFO"} |
Objeto válido e mensagem vazia |
{"s":1,"nota":"usa {chaves}"}Texto |
Chaves dentro da string não encerram o objeto |
Olá {"s":1}Texto |
LLMResponseParseError: texto antes do JSON |
| Cerca Markdown antes do JSON | Erro: primeiro caractere não é { |
{"s":1,}Texto |
JSON malformado |
{"s":1 |
Chaves não balanceadas |
{"s":"1"}Texto / {"s":true}Texto / {"s":1.0}Texto |
s não é inteiro aceito |
{"message":"Texto"} |
Campo s ausente |
[{"s":1}]Texto |
Não começa com objeto |
{"s":123}Texto |
Parser aceita; validate_code() rejeita no dispatcher |
Campos desconhecidos não são rejeitados universalmente. Os helpers de merge aceitam mapas e, na forma plana, filtram chaves reservadas; uma extensão descuidada pode acabar persistida como dado técnico/cadastral. Não use este contrato como substituto de um esquema fortemente tipado para todos os valores.
Reparo único e fallback¶
_invoke_and_parse() em src/core/application/process_message_use_case.py tenta o parser e, se ele lança LLMResponseParseError, chama _try_repair_llm_response_format(). Essa função faz uma chamada adicional a deps.llm.chat() com OPENAI_MODEL_SUMMARY, pedindo exclusivamente remover texto antes do JSON, cercas e espaços indevidos, preservando conteúdo. Sem JSON recuperável, instrui retornar INVALID_FORMAT.
flowchart TD
A[Resposta bruta] --> P[Parser estrito]
P -->|válida| D[Validar código e campos]
P -->|inválida| R[Uma chamada de reparo de formato]
R --> P2[Mesmo parser estrito]
P2 -->|válida| D
P2 -->|inválida / erro de provider| H[Handoff llm_invalid_json]
D -->|contrato inválido| H
D -->|ação válida| E[Aplicar ação]
Logs: llm_response_parse_retry, llm_response_repair_success, llm_response_repair_failed, llm_response_repair_unavailable; depois llm_failure_handoff se não houver reparo válido. O reparo é feito por LLM, não uma correção determinística com equivalência provada. Depois do reparo o código ainda passa pelo dispatcher e guards.
O LLMCallResult retornado no caminho reparado é o da chamada de reparo. As métricas desse outbound não somam automaticamente tokens/latência da tentativa original mais reparo. Da mesma forma, enriquecimentos silenciosos e chamadas de resumo não devem ser confundidos com um custo total calculável apenas pelas mensagens finais.
Timeout e retries do provider¶
OpenAIClient.chat() implementa Responses API e um loop de 1 + LLM_RETRY_MAX, default duas tentativas da aplicação. Repete timeout e status 5xx; status 4xx, incluindo 429, falha imediatamente nesse wrapper. Ao final lança LLMTimeoutError ou LLMProviderError.
O construtor do SDK não desabilita explicitamente os retries internos (max_retries não é informado), portanto não interprete duas tentativas do wrapper como limite comprovado de duas requisições HTTP efetivas. Veja OpenAI. Erros IntegrationError tratados pelo caso de uso recebem motivo llm_timeout, inclusive uma falha 4xx de provider: esse nome é a classificação implementada, não diagnóstico exclusivo de timeout.
Resumo operacional é outro contrato¶
generate_operational_summary() retorna JSON puro com motivo_encerramento, servico_identificado, segmento, pontos_chave, dados_coletados, proxima_acao, resumo_texto. Seu parser pode retirar cercas Markdown e normaliza tipos/defaults; falha retorna objeto mínimo com indicação de erro e não deve impedir o handoff. Não passe esse JSON pelo parser de ações nem acrescente s ao resumo.
Testes e alterações¶
tests/unit/application/test_parser.py cobre chaves em strings, escapes, prefixos, tipos e malformações. test_action_mapping.py cobre código/serviço/campos. test_process_message_use_case.py inclui test_text_before_json_gets_one_format_repair, test_invalid_json_triggers_handoff_with_fixed_message e test_invalid_model_contract_fails_into_visible_handoff. tests/unit/integrations/test_openai_client.py cobre Responses API e política do wrapper.
Ao mudar o formato, altere parser, todos os prompts, dispatcher, gravação do Message Log, reparo e fixtures como uma única mudança de contrato. Para adicionar apenas campo de negócio, escolha forma aninhada e teste sua persistência/validação. Veja códigos e payloads.