From d50dbabc1928788dcc1d233dd0231ee17737f396 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Fri, 1 May 2026 23:26:13 -0300 Subject: [PATCH 1/4] hitl: separar pergunta_original/pergunta_atual e reiniciar fluxo em nova pergunta - adiciona `pergunta_original` (imutavel) e `pergunta_atual` (fonte de verdade) ao estado - inicializa ambas no estado inicial do runtime - cria classificador de resposta humana com JSON estrito + fallback heuristico - atualiza `registrar_resposta_humana` para sempre anexar historico e, quando for `nova_pergunta`, resetar `sql_gerada`, `feedback_critico`, `erro_execucao`, `tentativas_loop`, `saida_terminal` e `status` para reexecutar o grafo limpo - migra prompts dos nos (planner/code_agent/critic/response) para usar `pergunta_atual` - ajusta prompt da resposta final para "pergunta atual" - atualiza fakes e testes (componentes, nos, integracao) e adiciona VCR de HITL - documenta o novo contrato de perguntas (README/ARQUITETURA/DESENVOLVIMENTO) O motivo do reset: quando a resposta humana vira uma nova pergunta, o estado anterior (SQL, feedback, erros e tentativas) passa a ser obsoleto e pode contaminar a nova execucao. Por isso, os campos sao limpos e o `status` volta para `iniciado`, garantindo que o fluxo replaneje, gere SQL e critique a partir da nova pergunta, sem herdar contexto incorreto. --- ARQUITETURA.md | 10 +- DESENVOLVIMENTO.md | 7 + README.md | 7 + .../test_hitl_nova_pergunta_substitui.yaml | 430 ++++++++++++++++++ tests/test_biblioteca_integracao.py | 6 +- tests/test_componentes.py | 10 +- tests/test_integracao.py | 33 +- tests/test_main_engine_integracao.py | 6 +- tests/test_nodes.py | 24 +- text_to_insight/InsightEngine.py | 7 +- text_to_insight/cli.py | 2 +- text_to_insight/graph.py | 1 + .../nodes/code_agent/code_agent.py | 6 +- text_to_insight/nodes/critic.py | 6 +- text_to_insight/nodes/planner.py | 6 +- text_to_insight/nodes/response.py | 8 +- text_to_insight/runtime.py | 170 ++++++- text_to_insight/state.py | 9 +- text_to_insight/utils.py | 6 +- 19 files changed, 719 insertions(+), 35 deletions(-) create mode 100644 tests/cassettes/test_integracao/test_hitl_nova_pergunta_substitui.yaml diff --git a/ARQUITETURA.md b/ARQUITETURA.md index 3226d33..0f1cbb0 100644 --- a/ARQUITETURA.md +++ b/ARQUITETURA.md @@ -131,7 +131,8 @@ Arquivo: `text_to_insight/state.py` Campos obrigatorios: -- `pergunta_usuario` +- `pergunta_original` +- `pergunta_atual` - `db_path` Campos principais do fluxo: @@ -141,6 +142,13 @@ Campos principais do fluxo: - `status`, `tentativas_loop`, `historico_conversa`, `espera_humana`, `pergunta_ao_usuario` - telemetria: `tokens_input`, `tokens_output`, `tokens_total` +## HITL e perguntas + +- `pergunta_original` e a pergunta inicial da thread (imutavel apos o primeiro set). +- `pergunta_atual` e a pergunta corrente e fonte de verdade para todo o fluxo. +- Em HITL, se a resposta do usuario for classificada como "nova pergunta", + o sistema atualiza `pergunta_atual` e reinicia o ciclo (sem alterar a original). + ## Status operacionais No runtime/engine podem aparecer: diff --git a/DESENVOLVIMENTO.md b/DESENVOLVIMENTO.md index 8811495..e607bd1 100644 --- a/DESENVOLVIMENTO.md +++ b/DESENVOLVIMENTO.md @@ -52,6 +52,13 @@ Criterios minimos: - retomada por `thread_id`; - gravacao de metricas em CSV. +## Contrato HITL (perguntas) + +- `pergunta_original`: pergunta inicial da thread, imutavel apos o primeiro set. +- `pergunta_atual`: pergunta corrente e fonte de verdade para o fluxo. +- Em HITL, se a resposta do usuario for classificada como "nova pergunta", + o sistema atualiza `pergunta_atual` e reinicia o ciclo, sem alterar a original. + ## Execucao ```bash diff --git a/README.md b/README.md index 24bd3dd..2aed368 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ O runtime padrao garante: - retomada por `thread_id`; - persistencia de metricas em `data/metricas_execucao.csv`. +## Contrato HITL (perguntas) + +- `pergunta_original`: pergunta inicial da thread, imutavel apos o primeiro set. +- `pergunta_atual`: pergunta corrente e fonte de verdade para o fluxo. +- Em HITL, se a resposta do usuario for classificada como "nova pergunta", + o sistema atualiza `pergunta_atual` e reinicia o ciclo, sem alterar a original. + ## Instalacao ```bash diff --git a/tests/cassettes/test_integracao/test_hitl_nova_pergunta_substitui.yaml b/tests/cassettes/test_integracao/test_hitl_nova_pergunta_substitui.yaml new file mode 100644 index 0000000..69ec996 --- /dev/null +++ b/tests/cassettes/test_integracao/test_hitl_nova_pergunta_substitui.yaml @@ -0,0 +1,430 @@ +interactions: +- request: + body: '{"contents": [{"parts": [{"text": "Voc\u00ea \u00e9 o planejador de um + sistema que transforma perguntas em consultas SQL.\n\nSeu papel: analisar a + situa\u00e7\u00e3o atual e decidir a pr\u00f3xima a\u00e7\u00e3o.\n\nContexto + atual:\n- Pergunta do usu\u00e1rio: \"Quem e o Brad Pitt?\"\n\n- conversa_previa: + Nenhuma\n\n- Schema: === SCHEMA SQLITE (INTROSPECCAO REAL) ===\n\nTabela: customers\n- + customer_id: TEXT (PK)\n- customer_unique_id: TEXT\n- customer_zip_code_prefix: + INTEGER\n- customer_city: TEXT\n- customer_state: TEXT\n\nTabela: geolocation\n- + geolocation_zip_code_prefix: INTEGER\n- geolocation_lat: REAL\n- geolocation_lng: + REAL\n- geolocation_city: TEXT\n- geolocation_state: TEXT\n\nTabela: order_items\n- + order_id: TEXT (PK)\n- order_item_id: INTEGER (PK)\n- product_id: TEXT\n- seller_id: + TEXT\n- shipping_limit_date: TEXT\n- price: \n\n- Feedback do cr\u00edtico: + Nenhum\n- Tentativas realizadas: 0\n- Status atual: schema_obtido\n- Erro anterior: + Nenhum\n\nAVALIA\u00c7\u00c3O CR\u00cdTICA:\nVerifique se a \"Pergunta do usu\u00e1rio\" + pode ser respondida com as tabelas e colunas do Schema.\nSe houver ambiguidade, + conceitos n\u00e3o mapeados no banco de dados, ou se a inten\u00e7\u00e3o do + usu\u00e1rio n\u00e3o estiver clara, voc\u00ea DEVE pedir mais informa\u00e7\u00f5es.\n\nResponda + EXATAMENTE no formato JSON abaixo, sem formata\u00e7\u00e3o markdown (```json):\n{{\n \"decisao\": + \"escolha_uma_opcao\",\n \"pergunta_ao_usuario\": \"escreva a pergunta aqui + se precisar de ajuda, ou deixe vazio se n\u00e3o precisar\"\n}}\n\nOp\u00e7\u00f5es + v\u00e1lidas para ''decis\u00e3o'':\n- \"pronto_codificacao\" \u2192 se temos + schema, a pergunta faz sentido e devemos gerar/regenerar SQL\n- \"revisando_estrategia\" + \u2192 se o cr\u00edtico reprovou e devemos tentar uma abordagem diferente\n- + \"necessita_ajuda\" \u2192 a pergunta n\u00e3o \u00e9 clara, n\u00e3o faz sentido, + falta contexto ou n\u00e3o h\u00e1 dados no schema para responder.\n"}], "role": + "user"}], "safetySettings": [], "generationConfig": {"temperature": 0.7, "candidateCount": + 1}}' + headers: + Accept: + - '*' + Accept-Encoding: + - g + Connection: + - k + Content-Length: + - '2' + Content-Type: + - a + Host: + - g + User-Agent: + - g + x-goog-api-client: + - g + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2WSz2rbQBDG73qKYS+5OKEJNja9lPQPJYdS05pQqEoYa8f2ptKO2FmVJMYPU3oI + BHrsE+jFOmtbjtzqIHZnvtlv9rezzgBMgd46i5HEvISvGgFYb/8pxz6Sj5roQhqsMcRn7e5b99Yq + iXSXisw692mfG0uFE+Rcg7nxVJCIi3iDt43F3Aw6WU1h2fiU4JtGGgxuX3IpEHFOJQpYJzX79vcP + cgK+/cWQumyfKnB+waHC9rH9QwLC80BQqxNrVcEVw8nrgBamLsaTM7jmon0Cktj+hDpw0QQFwVBz + AIuWBYLaFY79doNQlE5RkAyg5AJL95CM1J0bcApJG0tuipJf5Sb3G9NDsjmsvw2eQQYuKVGq2FLZ + yTedwCycd7L6RCjsk+zz7OPUHLLOW7rT8IusM9gebRrBJX2giPqkeHg4ozes6jjj7+TfcLN90uFk + uDutNwJHgtFkn48csTxKjS/Gg/8Olrdq68r+bPTGRm+p1OJ9usrs3ZeZ6ZGI//TVsch6yExccbNc + xeMezyejbA9tx/GagrgdsCVVivD04mx0utDJWW0dTaA0P0JXNmnK2+k54vzhvb1fPoyvpkOZT9zl + 0GSb7C9qGqlMHgMAAA== + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Fri, 01 May 2026 23:13:28 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=1922 + Transfer-Encoding: + - chunked + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Gemini-Service-Tier: + - standard + X-XSS-Protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "Voce e um classificador de respostas + de usuario em um fluxo HITL.\n\nTarefa:\n- Dado a pergunta original, a pergunta + atual e a resposta do usuario,\n classifique se o usuario fez uma NOVA PERGUNTA + ou se apenas ESCLARECEU\n algo da pergunta atual.\n\nRegras:\n- Responda ESTRITAMENTE + em JSON valido, sem markdown.\n- Campos obrigatorios: \"tipo\" e \"pergunta_normalizada\".\n- + \"tipo\" deve ser: \"nova_pergunta\" ou \"esclarecimento\".\n- Se for \"esclarecimento\", + mantenha \"pergunta_normalizada\" como a pergunta atual.\n- Se for \"nova_pergunta\", + normalize a nova pergunta a partir da resposta do usuario.\n\nEntrada:\nPergunta + original: \"Quem e o Brad Pitt?\"\nPergunta atual: \"Quem e o Brad Pitt?\"\nResposta + do usuario: \"Quero saber quantos clientes existem\"\n\nRetorne apenas:\n{\"tipo\":\"...\",\"pergunta_normalizada\":\"...\"}\n"}], + "role": "user"}], "safetySettings": [], "generationConfig": {"temperature": + 0.7, "candidateCount": 1}}' + headers: + Accept: + - '*' + Accept-Encoding: + - g + Connection: + - k + Content-Length: + - '9' + Content-Type: + - a + Host: + - g + User-Agent: + - g + x-goog-api-client: + - g + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2WR0U+DMBDG3/kryD1vZqLbkr0Y43yYiXEqMSZilsu4QSO0pD10G+F/t8DYOu0D + Kfd9ve/6a+X5PqxRxiJGJgMz/8NWfL9qv42mJJNkK/QlWyxQ88nbrcrZWwvTtjkEVQQsChXBLAKp + vnFVkE5KyRjBIIL+ZyWVzjETe4yxtT6XKFkZf50Jm07Gp60wTPlNBDU4SfVx/zk4zadVRk14rmLK + envdG2AjpDDpC6FRsrG9hk9LOKpCxrS15ZHXB7StoTSY0COxHZHxyAMKrfKCQ/VF8k6VLakgGHfd + HLLnhtFBZ8WYnUmTIBj8a2zmNlZkLnLnNewtLTreNVcJ799DcEjwn7l6Fp6DDDhVZZLy+YxX06l3 + gNZxfCNtRAcsodwiHAYX4+EmQ5O2iaDJFEoaWsQt+/nyEjfEi8nO7KeLpXjYi5/ba/Bq7xdrmUMi + dQIAAA== + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Fri, 01 May 2026 23:13:30 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=2302 + Transfer-Encoding: + - chunked + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Gemini-Service-Tier: + - standard + X-XSS-Protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "Voc\u00ea \u00e9 o planejador de um + sistema que transforma perguntas em consultas SQL.\n\nSeu papel: analisar a + situa\u00e7\u00e3o atual e decidir a pr\u00f3xima a\u00e7\u00e3o.\n\nContexto + atual:\n- Pergunta do usu\u00e1rio: \"Quantos clientes existem?\"\n\n- conversa_previa: + [[\"ai: As tabelas dispon\u00edveis n\u00e3o cont\u00eam informa\u00e7\u00f5es + sobre pessoas como ''Brad Pitt''. Voc\u00ea est\u00e1 procurando por dados relacionados + a clientes, localiza\u00e7\u00e3o ou itens de pedido?\", ''user: Quero saber + quantos clientes existem'']]\n\n- Schema: === SCHEMA SQLITE (INTROSPECCAO REAL) + ===\n\nTabela: customers\n- customer_id: TEXT (PK)\n- customer_unique_id: TEXT\n- + customer_zip_code_prefix: INTEGER\n- customer_city: TEXT\n- customer_state: + TEXT\n\nTabela: geolocation\n- geolocation_zip_code_prefix: INTEGER\n- geolocation_lat: + REAL\n- geolocation_lng: REAL\n- geolocation_city: TEXT\n- geolocation_state: + TEXT\n\nTabela: order_items\n- order_id: TEXT (PK)\n- order_item_id: INTEGER + (PK)\n- product_id: TEXT\n- seller_id: TEXT\n- shipping_limit_date: TEXT\n- + price: \n\n- Feedback do cr\u00edtico: Nenhum\n- Tentativas realizadas: 0\n- + Status atual: iniciado\n- Erro anterior: Nenhum\n\nAVALIA\u00c7\u00c3O CR\u00cdTICA:\nVerifique + se a \"Pergunta do usu\u00e1rio\" pode ser respondida com as tabelas e colunas + do Schema.\nSe houver ambiguidade, conceitos n\u00e3o mapeados no banco de dados, + ou se a inten\u00e7\u00e3o do usu\u00e1rio n\u00e3o estiver clara, voc\u00ea + DEVE pedir mais informa\u00e7\u00f5es.\n\nResponda EXATAMENTE no formato JSON + abaixo, sem formata\u00e7\u00e3o markdown (```json):\n{{\n \"decisao\": \"escolha_uma_opcao\",\n \"pergunta_ao_usuario\": + \"escreva a pergunta aqui se precisar de ajuda, ou deixe vazio se n\u00e3o precisar\"\n}}\n\nOp\u00e7\u00f5es + v\u00e1lidas para ''decis\u00e3o'':\n- \"pronto_codificacao\" \u2192 se temos + schema, a pergunta faz sentido e devemos gerar/regenerar SQL\n- \"revisando_estrategia\" + \u2192 se o cr\u00edtico reprovou e devemos tentar uma abordagem diferente\n- + \"necessita_ajuda\" \u2192 a pergunta n\u00e3o \u00e9 clara, n\u00e3o faz sentido, + falta contexto ou n\u00e3o h\u00e1 dados no schema para responder.\n"}], "role": + "user"}], "safetySettings": [], "generationConfig": {"temperature": 0.7, "candidateCount": + 1}}' + headers: + Accept: + - '*' + Accept-Encoding: + - g + Connection: + - k + Content-Length: + - '2' + Content-Type: + - a + Host: + - g + User-Agent: + - g + x-goog-api-client: + - g + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2WRXU+DMBSG7/kVpNebccwZ9c5ML7wwLo4YE1mWM3oGzaAl7cFsEv67LYytU5qQ + 9j1vz8fTJghDloLkggOhYQ/hl1XCsOn+LqYkoSQbGCQrVqDp7O2/xttbC+HeXWJNIt05YRxTYUAl + VkxYpW1atU4VF1uRQur00eCsUGe1JFiDWtemBi2Ot+ySLfPqtKf9anTuTqsCXelScSwGezsY2FZI + YfJ3BKOksy3jtwU7RYXkuLfydTAU6FKz2kCGr0hgOcGJhhukrChWO5RzVXecZlHUZ/O4Xhii+2Oc + FEFxEbq9m47+JTZPtqwofODeW9gpoRB0cKPEz58x80jQn74GFoGHjFGu6iynyx4nU2fuoPUcP1Ab + 0QPLsLQIx9HVbLwtwORdRabRVEoafOEd+5/FBFK5mX8vb0raLeRGzw6PigVt8AtwLS/ocwIAAA== + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Fri, 01 May 2026 23:13:32 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=1455 + Transfer-Encoding: + - chunked + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Gemini-Service-Tier: + - standard + X-XSS-Protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "Voc\u00ea \u00e9 um especialista em + SQL para bancos SQLite.\n\nSua tarefa: gerar UMA \u00fanica consulta SQL SELECT + que responda \u00e0 pergunta do usu\u00e1rio,\nusando o schema do banco de dados + fornecido abaixo.\n\nRegras:\n- Gere APENAS uma consulta SELECT (ou WITH/CTE + seguido de SELECT).\n- N\u00c3O use INSERT, UPDATE, DELETE, DROP, ALTER ou qualquer + comando de escrita.\n- N\u00c3O inclua explica\u00e7\u00f5es, apenas a SQL pura.\n- + Use nomes de tabelas e colunas EXATAMENTE como aparecem no schema.\n- Se a pergunta + for amb\u00edgua, fa\u00e7a a interpreta\u00e7\u00e3o mais razo\u00e1vel.\n\n=== + SCHEMA DO BANCO ===\n=== SCHEMA SQLITE (INTROSPECCAO REAL) ===\n\nTabela: customers\n- + customer_id: TEXT (PK)\n- customer_unique_id: TEXT\n- customer_zip_code_prefix: + INTEGER\n- customer_city: TEXT\n- customer_state: TEXT\n\nTabela: geolocation\n- + geolocation_zip_code_prefix: INTEGER\n- geolocation_lat: REAL\n- geolocation_lng: + REAL\n- geolocation_city: TEXT\n- geolocation_state: TEXT\n\nTabela: order_items\n- + order_id: TEXT (PK)\n- order_item_id: INTEGER (PK)\n- product_id: TEXT\n- seller_id: + TEXT\n- shipping_limit_date: TEXT\n- price: REAL\n- freight_value: REAL\n Foreign + keys:\n - seller_id -> sellers.seller_id (on_update=NO ACTION, on_delete=NO + ACTION)\n - product_id -> products.product_id (on_update=NO ACTION, on_delete=NO + ACTION)\n - order_id -> orders.order_id (on_update=NO ACTION, on_delete=NO + ACTION)\n\nTabela: order_payments\n- order_id: TEXT (PK)\n- payment_sequential: + INTEGER (PK)\n- payment_type: TEXT\n- payment_installments: INTEGER\n- payment_value: + REAL\n Foreign keys:\n - order_id -> orders.order_id (on_update=NO ACTION, + on_delete=NO ACTION)\n\nTabela: order_reviews\n- review_id: TEXT (PK)\n- order_id: + TEXT\n- review_score: INTEGER\n- review_comment_title: TEXT\n- review_comment_message: + TEXT\n- review_creation_date: TEXT\n- review_answer_timestamp: TEXT\n Foreign + keys:\n - order_id -> orders.order_id (on_update=NO ACTION, on_delete=NO ACTION)\n\nTabela: + orders\n- order_id: TEXT (PK)\n- customer_id: TEXT\n- order_status: TEXT\n- + order_purchase_timestamp: TEXT\n- order_approved_at: TEXT\n- order_delivered_carrier_date: + TEXT\n- order_delivered_customer_date: TEXT\n- order_estimated_delivery_date: + TEXT\n Foreign keys:\n - customer_id -> customers.customer_id (on_update=NO + ACTION, on_delete=NO ACTION)\n\nTabela: products\n- product_id: TEXT (PK)\n- + product_category_name: TEXT\n- product_name_length: REAL\n- product_description_length: + REAL\n- product_photos_qty: REAL\n- product_weight_g: REAL\n- product_length_cm: + REAL\n- product_height_cm: REAL\n- product_width_cm: REAL\n\nTabela: sellers\n- + seller_id: TEXT (PK)\n- seller_zip_code_prefix: INTEGER\n- seller_city: TEXT\n- + seller_state: TEXT\n\n\n=== PERGUNTA DO USU\u00c1RIO ===\nQuantos clientes existem?\n\n=== + CONVERSA PR\u00c9VIA (CONTEXTO ADICIONAL) ===\n[[\"ai: As tabelas dispon\u00edveis + n\u00e3o cont\u00eam informa\u00e7\u00f5es sobre pessoas como ''Brad Pitt''. + Voc\u00ea est\u00e1 procurando por dados relacionados a clientes, localiza\u00e7\u00e3o + ou itens de pedido?\", ''user: Quero saber quantos clientes existem'']]\n\n=== + FEEDBACK CR\u00cdTICO (SE HOUVER) ===\n\n\nResponda APENAS com a consulta SQL, + sem markdown, sem explica\u00e7\u00e3o."}], "role": "user"}], "safetySettings": + [], "generationConfig": {"temperature": 0.7, "candidateCount": 1}}' + headers: + Accept: + - '*' + Accept-Encoding: + - g + Connection: + - k + Content-Length: + - '3' + Content-Type: + - a + Host: + - g + User-Agent: + - g + x-goog-api-client: + - g + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/12QXUvDMBSG7/srQq4UrKzD7zvpJgzcBzOKKCJxOVvD0qQ0p7BZ9t9N22XLzEUI + 7/uec3KeOiKELrgWUnAESx/Ip1MIqdu78YxG0OgMLzmx4CUes92pg7eLIGyaIvoyfB6mjKTT1wk7 + W1QWTQ7ltxTn5Gk+HROvWBqU7w7vr4vj0NIoaDrmRoDy8Z0P0KXU0mZz4NbodjCbzujBlVrAxsm9 + yA9oW9PK8hWMAblbnx+WpEVp8gKZWYNOTdWuf9+/6boFuE4DexsNcnXiJL3E1waN7cCNlSrkGCB2 + W3IlcduswobvjAYk8N+/PIsoQEYxM9Uqw9M/3iXRnlmH8c2Rlx2vFeSOYNy/vI6XitusHUhLsIXR + FkaiyejBLOEfVTX5iZe/t6NZfrXN1o+GRrvoDyEzHVFJAgAA + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Fri, 01 May 2026 23:13:33 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=1115 + Transfer-Encoding: + - chunked + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Gemini-Service-Tier: + - standard + X-XSS-Protection: + - '0' + status: + code: 200 + message: OK +- request: + body: '{"contents": [{"parts": [{"text": "Voc\u00ea \u00e9 um revisor de qualidade + para consultas SQL geradas por IA.\n\nSua tarefa: avaliar se a consulta SQL + e seus resultados respondem adequadamente\n\u00e0 pergunta original do usu\u00e1rio.\n\n=== + PERGUNTA DO USU\u00c1RIO ===\nQuantos clientes existem?\n\n=== CONVERSA COM + O AGENTE (se houver) ===\n[[\"ai: As tabelas dispon\u00edveis n\u00e3o cont\u00eam + informa\u00e7\u00f5es sobre pessoas como ''Brad Pitt''. Voc\u00ea est\u00e1 + procurando por dados relacionados a clientes, localiza\u00e7\u00e3o ou itens + de pedido?\", ''user: Quero saber quantos clientes existem'']]\n\n=== SQL GERADA + ===\nSELECT COUNT(customer_id) FROM customers\n\n=== RESULTADO DA EXECU\u00c7\u00c3O + ===\nStatus: exec_ok\nTotal de linhas: 1\nAmostra dos resultados (primeiras + linhas):\n[{''COUNT(customer_id)'': 99441}]\n\n=== ERROS (se houver) ===\nNenhum\n\nAvalie:\n1. + A SQL responde \u00e0 pergunta do usu\u00e1rio?\n2. Os resultados fazem sentido?\n3. + H\u00e1 algum erro l\u00f3gico ou de interpreta\u00e7\u00e3o?\n\nResponda no + formato:\nVEREDITO: APROVADO ou REPROVADO\nFEEDBACK: "}], "role": "user"}], "safetySettings": [], "generationConfig": + {"temperature": 0.7, "candidateCount": 1}}' + headers: + Accept: + - '*' + Accept-Encoding: + - g + Connection: + - k + Content-Length: + - '1' + Content-Type: + - a + Host: + - g + User-Agent: + - g + x-goog-api-client: + - g + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2VSTW/TQBC951eM9gRSGjVVQtXeQuKKiBaniQlIgMgSb5It6x2zH1WbKP8FxAH1 + zI2r/xizsZw44IO1mnn73pu3s2kAsDnXqUy5E5ZdwgeqAGx2/9BD7YR21KhKVMy5cQds+W1qZ4I4 + 8RAusWk0jgbDJL6E3mgcT3uD+KO+iqLBy17/NdWA+K1XjsPk9hpmk+g66ifQj9++SZ7NvXWYCfNZ + ps/hahzfQFWxMzDC5qhTAak0wvGMPAoovkMuzNJr4ksRvPXFDyOxGVQcDYmAoIs/xIBAV+dKhmsW + NAfHvwjFYXaQaEEcVII5uji7uOh02jMonsBncM8VGtA+K56MnCN88wIWfA2W+CSh55iRfMZL4aXI + QJDwTwSeE6UI/oQxaEEVv5dEYAF9cCTJjsnDQMUvgrdYLdTt/vypeXgKg0qEnDNMharg2wrAFlJL + uxoLblEH2CSJR2zflRTgA5VPG5XAjpp5S55vyAUtBd8/PcsNZrlL8KvQffS7pTh70S3Zakt0BDiv + +g4dV0etTve0+R+xHZCsVPXtqi0eTcmVdI9hlCR6n7BaEu4fX1UWjVpkzK3QL1fu2GO7HQLYhVbm + OKX3l2Vg9HYU4clZq3uyUNyudoqs3D4rhmnA6LtRm6ej1av7u+n6fDhau3fj7NayxrbxF7u4rjZg + AwAA + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Fri, 01 May 2026 23:13:35 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=1607 + Transfer-Encoding: + - chunked + Vary: + - Origin + - X-Origin + - Referer + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Gemini-Service-Tier: + - standard + X-XSS-Protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_biblioteca_integracao.py b/tests/test_biblioteca_integracao.py index 645150b..55340c8 100644 --- a/tests/test_biblioteca_integracao.py +++ b/tests/test_biblioteca_integracao.py @@ -67,7 +67,11 @@ def stream(self, estado_execucao, config, stream_mode="values"): state = self._thread_state(thread_id) if estado_execucao is not None: - pergunta = str(estado_execucao.get("pergunta_usuario", "")) + pergunta = ( + str(estado_execucao.get("pergunta_atual", "")) + or str(estado_execucao.get("pergunta_original", "")) + or str(estado_execucao.get("pergunta_usuario", "")) + ) if "hitl" in pergunta.lower(): state["values"] = { **estado_execucao, diff --git a/tests/test_componentes.py b/tests/test_componentes.py index 00301ce..ffc0e15 100644 --- a/tests/test_componentes.py +++ b/tests/test_componentes.py @@ -22,7 +22,7 @@ def test_schema_extrai_tabelas(): """Schema node retorna contexto com tabelas do olist DB.""" from text_to_insight.nodes.schema import nos_nodo_esquema - estado = {"db_path": DB_PATH, "pergunta_usuario": "teste"} + estado = {"db_path": DB_PATH, "pergunta_atual": "teste"} resultado = nos_nodo_esquema(estado) assert resultado["status"] == "schema_obtido" @@ -34,7 +34,7 @@ def test_schema_erro_db_invalido(): """Schema node retorna erro quando db_path não existe.""" from text_to_insight.nodes.schema import nos_nodo_esquema - estado = {"db_path": "/caminho/inexistente.db", "pergunta_usuario": "teste"} + estado = {"db_path": "/caminho/inexistente.db", "pergunta_atual": "teste"} resultado = nos_nodo_esquema(estado) assert resultado["status"] == "exec_erro" @@ -145,7 +145,7 @@ def test_executor_sucesso(): estado = { "sql_gerada": "SELECT COUNT(*) as total FROM orders", "db_path": DB_PATH, - "pergunta_usuario": "teste", + "pergunta_atual": "teste", } resultado = nos_nodo_sandbox(estado) @@ -161,7 +161,7 @@ def test_executor_sql_vazia(): estado = { "sql_gerada": "", "db_path": DB_PATH, - "pergunta_usuario": "teste", + "pergunta_atual": "teste", } resultado = nos_nodo_sandbox(estado) @@ -175,7 +175,7 @@ def test_executor_sql_com_erro(): estado = { "sql_gerada": "SELECT * FROM tabela_que_nao_existe", "db_path": DB_PATH, - "pergunta_usuario": "teste", + "pergunta_atual": "teste", } resultado = nos_nodo_sandbox(estado) diff --git a/tests/test_integracao.py b/tests/test_integracao.py index 850ddb9..11820df 100644 --- a/tests/test_integracao.py +++ b/tests/test_integracao.py @@ -39,7 +39,8 @@ def rate_limit_delay(): def _estado_inicial(pergunta: str) -> dict: return { - "pergunta_usuario": pergunta, + "pergunta_original": pergunta, + "pergunta_atual": pergunta, "historico_conversa": [], "contexto_schema": "", "sql_gerada": "", @@ -97,4 +98,32 @@ def test_estado_final_completo(grafo): assert resultado.get("contexto_schema", "") != "" assert resultado.get("sql_gerada", "") != "" assert resultado.get("saida_terminal", "") != "" - assert resultado.get("tentativas_loop", 0) >= 1 \ No newline at end of file + assert resultado.get("tentativas_loop", 0) >= 1 + + +@pytest.mark.vcr +@pytest.mark.timeout(180) +def test_hitl_nova_pergunta_substitui(grafo): + """HITL com nova pergunta deve substituir pergunta_atual sem mudar a original.""" + from text_to_insight.runtime import registrar_resposta_humana + + config = {"configurable": {"thread_id": "teste_hitl_nova_pergunta"}} + grafo.grafo_text_to_insight.invoke(_estado_inicial("Quem e o Brad Pitt?"), config) + + snapshot = grafo.grafo_text_to_insight.get_state(config) + assert snapshot.values.get("espera_humana") is True or snapshot.values.get("status") == "aguardando_input" + + registrar_resposta_humana( + grafo_app=grafo.grafo_text_to_insight, + config=config, + user_response="Quero saber quantos clientes existem", + ) + + for _ in grafo.grafo_text_to_insight.stream(None, config, stream_mode="values"): + pass + + resultado_final = grafo.grafo_text_to_insight.get_state(config).values + + assert resultado_final.get("pergunta_original") == "Quem e o Brad Pitt?" + assert resultado_final.get("pergunta_atual") == "Quero saber quantos clientes existem" + assert "Brad Pitt" not in str(resultado_final.get("resposta_natural", "")) \ No newline at end of file diff --git a/tests/test_main_engine_integracao.py b/tests/test_main_engine_integracao.py index 1084661..04e533a 100644 --- a/tests/test_main_engine_integracao.py +++ b/tests/test_main_engine_integracao.py @@ -45,7 +45,11 @@ def stream(self, estado_execucao, config, stream_mode="values"): state = self._thread_state(thread_id) if estado_execucao is not None: - pergunta = str(estado_execucao.get("pergunta_usuario", "")) + pergunta = ( + str(estado_execucao.get("pergunta_atual", "")) + or str(estado_execucao.get("pergunta_original", "")) + or str(estado_execucao.get("pergunta_usuario", "")) + ) if "hitl" in pergunta.lower(): state["values"] = { **estado_execucao, diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 12f09d9..1934363 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -37,7 +37,7 @@ def llm(): def _obter_schema_real() -> str: """Helper: extrai schema real do olist DB (sem API, só SQLite).""" from text_to_insight.nodes.schema import nos_nodo_esquema - resultado = nos_nodo_esquema({"db_path": DB_PATH, "pergunta_usuario": "teste"}) + resultado = nos_nodo_esquema({"db_path": DB_PATH, "pergunta_atual": "teste"}) return resultado["contexto_schema"] @@ -50,7 +50,7 @@ def test_planner_sem_schema(llm): from text_to_insight.nodes.planner import nos_nodo_planejador estado = { - "pergunta_usuario": "Quantos pedidos existem?", + "pergunta_atual": "Quantos pedidos existem?", "contexto_schema": "", "feedback_critico": "", "status": "iniciado", @@ -73,7 +73,7 @@ def test_planner_com_schema_decide_codificar(llm): schema = _obter_schema_real() estado = { - "pergunta_usuario": "Quantos pedidos existem no banco?", + "pergunta_atual": "Quantos pedidos existem no banco?", "contexto_schema": schema, "feedback_critico": "", "status": "schema_obtido", @@ -95,7 +95,7 @@ def test_planner_com_feedback_revisa(llm): time.sleep(5) # rate limit schema = _obter_schema_real() estado = { - "pergunta_usuario": "Quantos pedidos existem no banco?", + "pergunta_atual": "Quantos pedidos existem no banco?", "contexto_schema": schema, "feedback_critico": "A SQL retornou dados incorretos, faltou filtrar por status.", "status": "reprovado", @@ -116,7 +116,7 @@ def test_planner_pergunta_fora_de_escopo(llm): time.sleep(5) # rate limit schema = _obter_schema_real() estado = { - "pergunta_usuario": "Quantas vezes a Ahri ganhou o CBLOL?", + "pergunta_atual": "Quantas vezes a Ahri ganhou o CBLOL?", "contexto_schema": schema, "feedback_critico": "", "status": "schema_obtido", @@ -145,7 +145,7 @@ def test_code_agent_gera_sql(llm): time.sleep(5) # rate limit schema = _obter_schema_real() estado = { - "pergunta_usuario": "Quantos pedidos existem no banco?", + "pergunta_atual": "Quantos pedidos existem no banco?", "contexto_schema": schema, "feedback_critico": "", "sql_gerada": "", @@ -173,7 +173,7 @@ def test_code_agent_com_feedback_regenera(llm): time.sleep(5) schema = _obter_schema_real() estado = { - "pergunta_usuario": "Quais as 5 categorias de produtos mais vendidas?", + "pergunta_atual": "Quais as 5 categorias de produtos mais vendidas?", "contexto_schema": schema, "feedback_critico": "A SQL anterior não tinha LIMIT 5, corrija.", "sql_gerada": "SELECT product_category_name FROM products", @@ -197,7 +197,7 @@ def test_executor_com_sql_real(): estado = { "sql_gerada": "SELECT COUNT(*) as total_pedidos FROM orders", "db_path": DB_PATH, - "pergunta_usuario": "Quantos pedidos existem?", + "pergunta_atual": "Quantos pedidos existem?", } resultado = nos_nodo_sandbox(estado) @@ -219,7 +219,7 @@ def test_critic_avalia_resultado_correto(llm): time.sleep(5) # rate limit estado = { - "pergunta_usuario": "Quantos pedidos existem no banco?", + "pergunta_atual": "Quantos pedidos existem no banco?", "sql_gerada": "SELECT COUNT(*) as total_pedidos FROM orders", "linhas_resultado_preview": [{"total_pedidos": 99441}], "total_linhas_resultado": 1, @@ -240,7 +240,7 @@ def test_critic_reprova_erro_execucao(llm): from text_to_insight.nodes.critic import nos_nodo_critico estado = { - "pergunta_usuario": "Quantos pedidos existem?", + "pergunta_atual": "Quantos pedidos existem?", "sql_gerada": "SELECT * FROM tabela_inexistente", "linhas_resultado_preview": [], "total_linhas_resultado": 0, @@ -271,7 +271,7 @@ def test_cadeia_code_agent_executor(llm): # Passo 1: Code Agent gera SQL estado_code = { - "pergunta_usuario": "Quantos clientes existem no banco?", + "pergunta_atual": "Quantos clientes existem no banco?", "contexto_schema": schema, "feedback_critico": "", "sql_gerada": "", @@ -286,7 +286,7 @@ def test_cadeia_code_agent_executor(llm): estado_exec = { "sql_gerada": resultado_code["sql_gerada"], "db_path": DB_PATH, - "pergunta_usuario": "Quantos clientes existem no banco?", + "pergunta_atual": "Quantos clientes existem no banco?", } resultado_exec = nos_nodo_sandbox(estado_exec) print(f" → Status execução: {resultado_exec['status']}") diff --git a/text_to_insight/InsightEngine.py b/text_to_insight/InsightEngine.py index 5ae11db..474bf2c 100644 --- a/text_to_insight/InsightEngine.py +++ b/text_to_insight/InsightEngine.py @@ -96,7 +96,12 @@ def get_insight( if snapshot.next and user_response: registrar_resposta_humana(app, config, user_response) estado_execucao = None - pergunta_exibicao = snapshot.values.get("pergunta_usuario", "Retomando conversa...") + pergunta_exibicao = ( + snapshot.values.get("pergunta_atual") + or snapshot.values.get("pergunta_original") + or snapshot.values.get("pergunta_usuario") + or "Retomando conversa..." + ) # Caso 2: chamada nova (primeira execução para essa pergunta). elif query: estado_execucao = construir_estado_inicial(query, self._db_path) diff --git a/text_to_insight/cli.py b/text_to_insight/cli.py index 2b47c0a..c3a767e 100644 --- a/text_to_insight/cli.py +++ b/text_to_insight/cli.py @@ -90,7 +90,7 @@ def main(argv: list[str] | None = None) -> dict[str, Any]: callback = _coletar_resposta_humana if hitl_ativado else None resultado = engine.run(thread_id=args.thread_id, query=pergunta, on_human_prompt=callback) - # Fallback para clientes que prefiram retomar manualmente sem callback. + # Fallback (plano B) para clientes que prefiram retomar manualmente sem callback. while resultado.get("status") == "AWAITING_USER": resposta = _coletar_resposta_humana(resultado.get("message", "Pode confirmar o prosseguimento?")) resultado = engine.resume( diff --git a/text_to_insight/graph.py b/text_to_insight/graph.py index 43e4552..7e8d720 100644 --- a/text_to_insight/graph.py +++ b/text_to_insight/graph.py @@ -106,6 +106,7 @@ def _compilar_grafo(self, hitl: bool) -> "CompiledStateGraph": construtor = self._construir_grafo_text_to_insight(hitl) grafo_compilado = construtor.compile(checkpointer=self.memory, interrupt_before=["espera_humana"]) + grafo_compilado.hitl_classifier_llm = self.llm print("[GRAFO] Grafo Text-to-Insight compilado com sucesso!") return grafo_compilado diff --git a/text_to_insight/nodes/code_agent/code_agent.py b/text_to_insight/nodes/code_agent/code_agent.py index e0557ba..553c50e 100644 --- a/text_to_insight/nodes/code_agent/code_agent.py +++ b/text_to_insight/nodes/code_agent/code_agent.py @@ -52,7 +52,11 @@ def nos_nodo_agente_codigo(estado: EstadoTextToInsight, llm: ChatGoogleGenerativ """ Nó Agente de Código: usa Gemini para gerar SQL a partir da pergunta + schema. """ - pergunta = estado.get("pergunta_usuario", "") + pergunta = ( + estado.get("pergunta_atual", "") + or estado.get("pergunta_original", "") + or estado.get("pergunta_usuario", "") + ) conversa_previa = estado.get("historico_conversa", "") schema = estado.get("contexto_schema", "") feedback = estado.get("feedback_critico", "") diff --git a/text_to_insight/nodes/critic.py b/text_to_insight/nodes/critic.py index 04e53c7..8a27a93 100644 --- a/text_to_insight/nodes/critic.py +++ b/text_to_insight/nodes/critic.py @@ -47,7 +47,11 @@ def nos_nodo_critico(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) - """ Nó Crítico: usa Gemini para avaliar qualidade do resultado. """ - pergunta = estado.get("pergunta_usuario", "") + pergunta = ( + estado.get("pergunta_atual", "") + or estado.get("pergunta_original", "") + or estado.get("pergunta_usuario", "") + ) sql = estado.get("sql_gerada", "") preview = estado.get("linhas_resultado_preview", []) total = estado.get("total_linhas_resultado", 0) diff --git a/text_to_insight/nodes/planner.py b/text_to_insight/nodes/planner.py index ed7a33b..3ff9bdd 100644 --- a/text_to_insight/nodes/planner.py +++ b/text_to_insight/nodes/planner.py @@ -38,7 +38,11 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI Lógica determinística para schema vazio; LLM para decisões mais complexas. """ - pergunta = estado.get("pergunta_usuario", "") + pergunta = ( + estado.get("pergunta_atual", "") + or estado.get("pergunta_original", "") + or estado.get("pergunta_usuario", "") + ) conversa_previa = estado.get("historico_conversa", "") schema = estado.get("contexto_schema", "") feedback = estado.get("feedback_critico", "") diff --git a/text_to_insight/nodes/response.py b/text_to_insight/nodes/response.py index 1e6fc35..cf0a403 100644 --- a/text_to_insight/nodes/response.py +++ b/text_to_insight/nodes/response.py @@ -18,7 +18,7 @@ usuário final. Instruções: -- Use a pergunta original e a SQL executada como contexto. +- Use a pergunta atual e a SQL executada como contexto. - Inclua um resumo do que os resultados indicam e, quando relevante, uma interpretação simples (por exemplo: totais, médias, top N, ausência de dados, etc.). - Seja claro sobre quaisquer limitações (por exemplo: amostra limitada de linhas). @@ -44,7 +44,11 @@ def nos_nodo_resposta(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) Não altera o status além de mantê-lo como 'aprovado'. """ status = estado.get("status", "") - pergunta = estado.get("pergunta_usuario", "") + pergunta = ( + estado.get("pergunta_atual", "") + or estado.get("pergunta_original", "") + or estado.get("pergunta_usuario", "") + ) sql = estado.get("sql_gerada", "") preview = estado.get("linhas_resultado_preview", []) total = estado.get("total_linhas_resultado", None) diff --git a/text_to_insight/runtime.py b/text_to_insight/runtime.py index c1a7114..659c812 100644 --- a/text_to_insight/runtime.py +++ b/text_to_insight/runtime.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import re import time from typing import Any, Callable @@ -8,11 +10,35 @@ HITL_AWAITING_STATUS = "AWAITING_USER" HITL_BLOCKED_STATUS = "bloqueado_hitl" +PROMPT_CLASSIFICADOR_HITL = """Voce e um classificador de respostas de usuario em um fluxo HITL. + +Tarefa: +- Dado a pergunta original, a pergunta atual e a resposta do usuario, + classifique se o usuario fez uma NOVA PERGUNTA ou se apenas ESCLARECEU + algo da pergunta atual. + +Regras: +- Responda ESTRITAMENTE em JSON valido, sem markdown. +- Campos obrigatorios: "tipo" e "pergunta_normalizada". +- "tipo" deve ser: "nova_pergunta" ou "esclarecimento". +- Se for "esclarecimento", mantenha "pergunta_normalizada" como a pergunta atual. +- Se for "nova_pergunta", normalize a nova pergunta a partir da resposta do usuario. + +Entrada: +Pergunta original: "{pergunta_original}" +Pergunta atual: "{pergunta_atual}" +Resposta do usuario: "{user_response}" + +Retorne apenas: +{{"tipo":"...","pergunta_normalizada":"..."}} +""" + def construir_estado_inicial(pergunta: str, db_path: str) -> dict[str, Any]: """Cria o estado inicial padrão para uma execução do grafo.""" return { - "pergunta_usuario": pergunta, + "pergunta_original": pergunta, + "pergunta_atual": pergunta, "contexto_schema": "", "sql_gerada": "", "saida_terminal": "", @@ -26,6 +52,109 @@ def construir_estado_inicial(pergunta: str, db_path: str) -> dict[str, Any]: } +def _limpar_json_markdown(conteudo: str) -> str: + conteudo_limpo = conteudo.strip() + if conteudo_limpo.startswith("```json"): + conteudo_limpo = conteudo_limpo[7:] + if conteudo_limpo.endswith("```"): + conteudo_limpo = conteudo_limpo[:-3] + elif conteudo_limpo.startswith("```"): + conteudo_limpo = conteudo_limpo[3:] + if conteudo_limpo.endswith("```"): + conteudo_limpo = conteudo_limpo[:-3] + return conteudo_limpo.strip() + + +def _heuristica_nova_pergunta(resposta: str) -> bool: + texto = (resposta or "").strip() + if not texto: + return False + + texto_lower = texto.lower() + confirmacoes = { + "sim", + "ok", + "certo", + "pode", + "pode prosseguir", + "pode seguir", + "continue", + "prosseguir", + "segue", + } + if texto_lower in confirmacoes or texto_lower.startswith(("sim ", "ok ", "certo ")): + return False + + if texto_lower.endswith("?"): + return True + + padroes = [ + r"\bquero saber\b", + r"\bgostaria de saber\b", + r"\bpreciso saber\b", + r"\bme diga\b", + r"\bme informe\b", + r"\bme mostre\b", + r"\bqual\b", + r"\bquais\b", + r"\bquantos?\b", + r"\bquanto\b", + r"\bquando\b", + r"\bonde\b", + r"\bcomo\b", + r"\bquem\b", + r"\bpor que\b", + r"\bporque\b", + r"\bnova pergunta\b", + ] + return any(re.search(padrao, texto_lower) for padrao in padroes) + + +def classificar_resposta_usuario( + pergunta_original: str, + pergunta_atual: str, + user_response: str, + llm: Any | None = None, +) -> dict[str, str]: + pergunta_original = (pergunta_original or "").strip() + pergunta_atual = (pergunta_atual or "").strip() + resposta = (user_response or "").strip() + + if llm is not None: + prompt = PROMPT_CLASSIFICADOR_HITL.format( + pergunta_original=pergunta_original, + pergunta_atual=pergunta_atual, + user_response=resposta, + ) + try: + resposta_llm = llm.invoke(prompt) + conteudo_bruto = _limpar_json_markdown(str(getattr(resposta_llm, "content", ""))) + dados = json.loads(conteudo_bruto) + tipo = str(dados.get("tipo", "")).strip().lower() + pergunta_normalizada = str(dados.get("pergunta_normalizada", "")).strip() + if tipo in {"nova_pergunta", "esclarecimento"}: + if tipo == "nova_pergunta" and resposta: + pergunta_normalizada = resposta + if not pergunta_normalizada: + pergunta_normalizada = pergunta_atual if tipo == "esclarecimento" else resposta + if pergunta_normalizada: + return { + "tipo": tipo, + "pergunta_normalizada": pergunta_normalizada, + } + except Exception: + pass + + if _heuristica_nova_pergunta(resposta): + pergunta_normalizada = resposta if resposta else pergunta_atual or pergunta_original + return {"tipo": "nova_pergunta", "pergunta_normalizada": pergunta_normalizada} + + return { + "tipo": "esclarecimento", + "pergunta_normalizada": pergunta_atual or pergunta_original or resposta, + } + + def exibir_resultado_console(resultado: dict[str, Any]) -> None: """Exibe o resultado final de forma consistente entre CLI e engine.""" print("\n" + "=" * 70) @@ -105,7 +234,44 @@ def registrar_resposta_humana(grafo_app: Any, config: dict[str, Any], user_respo historico = list(snapshot.values.get("historico_conversa", [])) pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") historico.append((f"ai: {pergunta_agente}", f"user: {user_response}")) - grafo_app.update_state(config, {"historico_conversa": historico, "espera_humana": False}) + pergunta_original = snapshot.values.get("pergunta_original", "") + pergunta_atual = snapshot.values.get("pergunta_atual", "") + if not pergunta_atual: + pergunta_atual = snapshot.values.get("pergunta_usuario", "") + + llm = getattr(grafo_app, "hitl_classifier_llm", None) + if llm is None: + llm = getattr(grafo_app, "llm", None) + + classificacao = classificar_resposta_usuario( + pergunta_original=pergunta_original, + pergunta_atual=pergunta_atual, + user_response=user_response, + llm=llm, + ) + + updates = { + "historico_conversa": historico, + "espera_humana": False, + } + + if not pergunta_original: + updates["pergunta_original"] = pergunta_atual or user_response + + if classificacao.get("tipo") == "nova_pergunta": + updates.update( + { + "pergunta_atual": classificacao.get("pergunta_normalizada", pergunta_atual), + "sql_gerada": "", + "feedback_critico": "", + "erro_execucao": "", + "tentativas_loop": 0, + "saida_terminal": "", + "status": "iniciado", + } + ) + + grafo_app.update_state(config, updates) def executar_fluxo( diff --git a/text_to_insight/state.py b/text_to_insight/state.py index 9d32550..69e5a2a 100644 --- a/text_to_insight/state.py +++ b/text_to_insight/state.py @@ -25,9 +25,11 @@ "reprovado", ] -# Criação de classe mãe que será estendida para EstadoTextToInsight para que pergunta_usuario e db_path sejam obrigatórios +# Criação de classe mãe que será estendida para EstadoTextToInsight para que +# pergunta_original/pergunta_atual e db_path sejam obrigatorios class EstadoEntrada(TypedDict): - pergunta_usuario: str + pergunta_original: str + pergunta_atual: str db_path: str @@ -36,7 +38,8 @@ class EstadoTextToInsight(EstadoEntrada, total = False): Estado compartilhado do grafo Text-to-Insight (MVP SQL-only). Campos obrigatórios (via EstadoEntrada): - - pergunta_usuario: pergunta em linguagem natural. + - pergunta_original: pergunta inicial do usuario (imutavel apos o primeiro set). + - pergunta_atual: pergunta corrente, fonte de verdade para o fluxo. - db_path: caminho para o arquivo SQLite (.db). Campos opcionais (preenchidos progressivamente pelos nós): diff --git a/text_to_insight/utils.py b/text_to_insight/utils.py index df2f6bf..6d9123d 100644 --- a/text_to_insight/utils.py +++ b/text_to_insight/utils.py @@ -23,7 +23,11 @@ def salvar_metricas_csv(resultado: dict, latencia: float, arquivo_csv="data/metr # Colunas dados = { "data_hora": datetime.now(), - "pergunta": resultado.get("pergunta_usuario", ""), + "pergunta": ( + resultado.get("pergunta_atual", "") + or resultado.get("pergunta_original", "") + or resultado.get("pergunta_usuario", "") + ), "status_final": resultado.get("status", ""), "tentativas": resultado.get("tentativas_loop", 0), "tokens_input": resultado.get("tokens_input", 0), From fca34951af276c4f62fa05740e610610dfd9d593 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Fri, 1 May 2026 23:36:21 -0300 Subject: [PATCH 2/4] =?UTF-8?q?breve=20coment=C3=A1rio=20na=20fun=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20heuristica=20de=20runtime.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- text_to_insight/runtime.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/text_to_insight/runtime.py b/text_to_insight/runtime.py index 659c812..4469016 100644 --- a/text_to_insight/runtime.py +++ b/text_to_insight/runtime.py @@ -71,6 +71,7 @@ def _heuristica_nova_pergunta(resposta: str) -> bool: return False texto_lower = texto.lower() + # Confirmacoes curtas tipicas de prosseguimento nao devem reiniciar o fluxo. confirmacoes = { "sim", "ok", @@ -85,9 +86,11 @@ def _heuristica_nova_pergunta(resposta: str) -> bool: if texto_lower in confirmacoes or texto_lower.startswith(("sim ", "ok ", "certo ")): return False + # Pergunta explicita e um forte sinal de nova intencao. if texto_lower.endswith("?"): return True + # Padroes que indicam nova solicitacao (intencao de perguntar algo novo) padroes = [ r"\bquero saber\b", r"\bgostaria de saber\b", @@ -107,6 +110,7 @@ def _heuristica_nova_pergunta(resposta: str) -> bool: r"\bporque\b", r"\bnova pergunta\b", ] + # se a resposta do usuario contiver alguma expressao dos padrões, devolve True; caso contrario, devolve False. return any(re.search(padrao, texto_lower) for padrao in padroes) From 09d91e95f50bc761c8f36d0bfc695f13af1ba759 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Sat, 2 May 2026 19:09:26 -0300 Subject: [PATCH 3/4] passagem de schema inteiro para planner --- text_to_insight/nodes/planner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text_to_insight/nodes/planner.py b/text_to_insight/nodes/planner.py index 3ff9bdd..5827033 100644 --- a/text_to_insight/nodes/planner.py +++ b/text_to_insight/nodes/planner.py @@ -113,8 +113,8 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI tentativas=tentativas, status_atual=status, erro=erro if erro else "Nenhum", - # apenas primeiros 500 caracteres do schema para evitar estourar o prompt, mas pode ser ajustado conforme necessidade - schema=schema[:500] if schema else "Nenhum", + # passamos schema inteiro agora + schema=schema if schema else "Nenhum", conversa_previa=conversa_previa if conversa_previa else "Nenhuma", diretrizes=diretrizes, ) From 2f5f0ccbf5cb2a8a297c50e9b35755a506fefea4 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Sat, 16 May 2026 17:11:51 -0300 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20adiciona=20download=20do=20banco=20v?= =?UTF-8?q?ia=20release=20no=20CI;=20fix:=20adiciona=20novo=20tratamento?= =?UTF-8?q?=20de=20perguntas=20do=20usu=C3=A1rio=20com=20rest=20de=20'perg?= =?UTF-8?q?unta=5Fatual'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 140a064..eea8b4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,14 @@ jobs: pip install -r requirements.txt pip install -e . + - name: Ensure that the data folder exists + run: mkdir -p data + + - name: Download the relase database for test + run: gh release download dados-teste -p "olist_relational.db" -D data/ + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run deterministic test layers run: | pytest \