Pular para conteúdo

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

  1. Aplica lstrip() para tolerar whitespace inicial do provider.
  2. Exige { como primeiro caractere restante.
  3. Percorre contando chaves, respeitando strings, barras de escape e aspas escapadas.
  4. O primeiro } que zera a profundidade delimita o JSON.
  5. Faz json.loads() somente desse trecho; o restante recebe lstrip() e vira mensagem.
  6. Exige objeto com s do tipo inteiro. bool é recusado explicitamente, mesmo sendo subtipo de int em 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.