From d91892354506ee72880b73bab17d808c24047b3e Mon Sep 17 00:00:00 2001 From: Fuganti Date: Thu, 2 Apr 2026 12:47:30 -0300 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20Adiciona=20Human-In-The-Loop=20(HIT?= =?UTF-8?q?L),=20Agentic=20Planner=20e=20atualiza=20testes=20-=20Planner?= =?UTF-8?q?=20retorna=20um=20JSON=20com=20decis=C3=A3o=20e=20pergunta=20-?= =?UTF-8?q?=20Agora=20o=20schema=20sempre=20passa=20pelo=20Planner=20para?= =?UTF-8?q?=20que=20ele=20avalie=20se=20a=20pergunta=20=C3=A9=20clara,=20o?= =?UTF-8?q?u=20n=C3=A3o=20antes=20de=20chamar=20o=20code=5Fagent=20-=20Na?= =?UTF-8?q?=20main,=20ao=20inv=C3=A9s=20de=20usarmos=20um=20simples=20Invo?= =?UTF-8?q?ke,=20adicionei=20mem=C3=B3ria=20para=20que=20a=20pergunta=20do?= =?UTF-8?q?=20usu=C3=A1rio=20seja=20feita=20fora=20do=20Grafo,=20desse=20m?= =?UTF-8?q?odo=20podemos=20adicionar=20mais=20intera=C3=A7=C3=B5es=20poste?= =?UTF-8?q?riormente=20-=20Caso=20a=20pergunta=20seja=20absurda/n=C3=A3o?= =?UTF-8?q?=20esta=20clara,=20o=20grafo=20=C3=A9=20interrompido=20e=20?= =?UTF-8?q?=C3=A9=20adicionada=20uma=20pergunta=20do=20agente=20ao=20estad?= =?UTF-8?q?o,=20abrindo=20espa=C3=A7o=20para=20um=20input=20do=20usu=C3=A1?= =?UTF-8?q?rio=20-=20Mudei=20os=20testes=20para=20que=20estivessem=20coere?= =?UTF-8?q?ntes=20com=20essa=20nova=20estrutura,=20adicionei=20um=20teste?= =?UTF-8?q?=20novo=20e=20re-rodei=20os=20testes=20em=20vcr=20para=20captur?= =?UTF-8?q?ar=20as=20mudan=C3=A7as=20do=20Planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 23 ++- src/graph.py | 21 ++- src/nodes/planner.py | 51 +++++- src/routers/edges.py | 11 +- src/state.py | 2 + .../test_estado_final_completo.yaml | 139 +++++++++++---- .../test_pergunta_com_ranking.yaml | 159 +++++++++++++----- .../test_pergunta_simples.yaml | 116 ++++++++++--- .../test_cadeia_code_agent_executor.yaml | 16 +- ...test_code_agent_com_feedback_regenera.yaml | 20 +-- .../test_nodes/test_code_agent_gera_sql.yaml | 16 +- .../test_critic_avalia_resultado_correto.yaml | 91 ++-------- .../test_planner_com_feedback_revisa.yaml | 118 +++++++++++-- ...t_planner_com_schema_decide_codificar.yaml | 41 +++-- .../test_planner_pergunta_fora_de_escopo.yaml | 84 +++++++++ tests/test_componentes.py | 11 ++ tests/test_integracao.py | 17 +- tests/test_nodes.py | 28 ++- 18 files changed, 712 insertions(+), 252 deletions(-) create mode 100644 tests/cassettes/test_nodes/test_planner_pergunta_fora_de_escopo.yaml diff --git a/main.py b/main.py index bf7763c..3d6b9d4 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: """Executa uma consulta através do grafo Text-to-Insight.""" + config = {"configurable": {"thread_id": "sessao_usuario_1"}} estado_inicial = { "pergunta_usuario": pergunta, "contexto_schema": "", @@ -23,6 +24,7 @@ def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: "status": "iniciado", "tentativas_loop": 0, "db_path": "data/olist_relational.db", + "espera_humana": False, } print("=" * 70) @@ -31,10 +33,27 @@ def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: print(f"\nPergunta: {pergunta}\n") print("=" * 70) - resultado_final = grafo.invoke(estado_inicial) - return resultado_final + while True: + for evento in grafo.grafo_text_to_insight.stream(estado_inicial, config, stream_mode="values"): + pass + snapshot = grafo.grafo_text_to_insight.get_state(config) + if not snapshot.next: + return snapshot.values + + if "espera_humana" in snapshot.next: + pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") + pergunta_original = snapshot.values.get("pergunta_usuario", "") + print(f"\n[HITL]: {pergunta_agente}") + + resposta = input("Resposta: ") + + grafo.grafo_text_to_insight.update_state(config, {f"pergunta_usuario": f"{pergunta_original}.\n[Contexto Adicional do Usuário]: {resposta}", + "espera_humana": False}) + + estado_inicial = None + def exibir_resultado(resultado: dict) -> None: """Exibe o resultado final da execução de forma formatada.""" print("\n" + "=" * 70) diff --git a/src/graph.py b/src/graph.py index dfd93e9..52b409e 100644 --- a/src/graph.py +++ b/src/graph.py @@ -14,6 +14,7 @@ from langgraph.graph import StateGraph, START, END from langchain_google_genai import ChatGoogleGenerativeAI +from langgraph.checkpoint.memory import MemorySaver from .state import EstadoTextToInsight from .nodes import ( @@ -26,6 +27,10 @@ ) from .routers import roteador_sandbox, roteador_planejador +def nos_nodo_espera_humana(estado: EstadoTextToInsight): + """Nó estrutural: serve apenas como breakpoint para o HITL.""" + return estado + class Graph: def __init__(self, api_key): #Essa definição do grafo pode mudar pro caso de utilizarmos diferentes modelos @@ -34,6 +39,7 @@ def __init__(self, api_key): #Essa definição do grafo pode mudar pro caso de u #um campo pro usuário utilizar mais modelos no futuro google_api_key=api_key ) + self.memory = MemorySaver() self.grafo_text_to_insight = self._compilar_grafo() def _construir_grafo_text_to_insight(self) -> StateGraph: @@ -44,6 +50,7 @@ def _construir_grafo_text_to_insight(self) -> StateGraph: # 1. ADICIONAR NÓS construtor_grafo.add_node("planejador", partial(nos_nodo_planejador, llm=self.llm)) + construtor_grafo.add_node("espera_humana", nos_nodo_espera_humana) construtor_grafo.add_node("esquema", nos_nodo_esquema) construtor_grafo.add_node("agente_codigo", partial(nos_nodo_agente_codigo, llm=self.llm)) construtor_grafo.add_node("sandbox", nos_nodo_sandbox) @@ -52,9 +59,10 @@ def _construir_grafo_text_to_insight(self) -> StateGraph: # 2. ARESTAS FIXAS construtor_grafo.add_edge(START, "planejador") - construtor_grafo.add_edge("esquema", "agente_codigo") + construtor_grafo.add_edge("espera_humana", "planejador") + construtor_grafo.add_edge("esquema", "planejador") construtor_grafo.add_edge("agente_codigo", "sandbox") - + # 3. ARESTAS CONDICIONAIS construtor_grafo.add_conditional_edges( "sandbox", @@ -69,9 +77,11 @@ def _construir_grafo_text_to_insight(self) -> StateGraph: "planejador", roteador_planejador, { + "espera_humana": "espera_humana", "esquema": "esquema", "agente_codigo": "agente_codigo", "critico": "critico", + "planejador": "planejador", "fim": END, } ) @@ -99,9 +109,10 @@ def roteador_critico(estado: EstadoTextToInsight) -> str: def _compilar_grafo(self) -> "CompiledStateGraph": construtor = self._construir_grafo_text_to_insight() - grafo_compilado = construtor.compile() + grafo_compilado = construtor.compile(checkpointer=self.memory, + interrupt_before=["espera_humana"]) print("[GRAFO] Grafo Text-to-Insight compilado com sucesso!") return grafo_compilado - def invoke(self, estado: EstadoTextToInsight): - return self.grafo_text_to_insight.invoke(estado) + def app(self): + return self.grafo_text_to_insight diff --git a/src/nodes/planner.py b/src/nodes/planner.py index 279fba9..1bf69b9 100644 --- a/src/nodes/planner.py +++ b/src/nodes/planner.py @@ -5,6 +5,7 @@ para decidir a próxima etapa do fluxo (status de roteamento). """ +import json from langchain_google_genai import ChatGoogleGenerativeAI from ..state import EstadoTextToInsight @@ -21,12 +22,21 @@ - Status atual: {status_atual} - Erro anterior: {erro} -Decida a próxima ação respondendo com EXATAMENTE uma das opções abaixo: -- "pronto_codificacao" → se temos schema e devemos gerar/regenerar SQL -- "revisando_estrategia" → se o crítico reprovou e devemos tentar uma abordagem diferente -- "aprovado" → se o resultado já foi aprovado pelo crítico +AVALIAÇÃO CRÍTICA: +Verifique se a "Pergunta do usuário" pode ser respondida com as tabelas e colunas do Schema. +Se houver ambiguidade, conceitos não mapeados no banco de dados, ou se a intenção do usuário não estiver clara, você DEVE pedir mais informações. + +Responda EXATAMENTE no formato JSON abaixo, sem formatação markdown (```json): +{{ + "decisao": "escolha_uma_opcao", + "pergunta_ao_usuario": "escreva a pergunta aqui se precisar de ajuda, ou deixe vazio se não precisar" +}} -Responda APENAS com uma das opções acima, sem explicação.""" +Opções válidas para 'decisão': +- "pronto_codificacao" → se temos schema, a pergunta faz sentido e devemos gerar/regenerar SQL +- "revisando_estrategia" → se o crítico reprovou e devemos tentar uma abordagem diferente +- "necessita_ajuda" → a pergunta não é clara, não faz sentido, falta contexto ou não há dados no schema para responder. +""" def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) -> dict: @@ -67,8 +77,34 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI erro=erro if erro else "Nenhum", ) - resposta = llm.invoke(prompt) - decisao = resposta.content.strip().strip('"').lower() + resposta_llm = llm.invoke(prompt) + conteudo_bruto = resposta_llm.content.strip() + + # Limpeza caso o LLM retorne blocos de código markdown (```json ... ```) + if conteudo_bruto.startswith("```json"): + conteudo_bruto = conteudo_bruto[7:-3].strip() + elif conteudo_bruto.startswith("```"): + conteudo_bruto = conteudo_bruto[3:-3].strip() + + try: + dados_resposta = json.loads(conteudo_bruto) + decisao = dados_resposta.get("decisao", "").lower() + pergunta_agente = dados_resposta.get("pergunta_ao_usuario", "").strip() + except json.JSONDecodeError: + print(f"[PLANEJADOR] Erro ao parsear JSON: {conteudo_bruto}") + # Fallback de segurança + decisao = "revisando_estrategia" if feedback else "pronto_codificacao" + pergunta_agente = "" + + # Mapeia para os estados do grafo e levanta a flag de HITL se necessário + if decisao == "necessita_ajuda": + print(f"[PLANEJADOR] Solicitando ajuda: {pergunta_agente}") + return { + "status": "aguardando_input", + "espera_humana": True, # Flag que o router vai ler! + "pergunta_ao_usuario": pergunta_agente, # A pergunta que vai aparecer no terminal! + "tentativas_loop": tentativas + } # Mapeia resposta para status válido status_validos = ["pronto_codificacao", "revisando_estrategia", "aprovado"] @@ -80,5 +116,6 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI return { "status": decisao, + "espera_humana": False, "tentativas_loop": tentativas, } diff --git a/src/routers/edges.py b/src/routers/edges.py index dc6e863..673f780 100644 --- a/src/routers/edges.py +++ b/src/routers/edges.py @@ -44,14 +44,19 @@ def roteador_planejador(estado: EstadoTextToInsight) -> Literal["esquema", "agen """ contexto = estado.get("contexto_schema", "") status = estado.get("status", "") + esperar = estado.get("espera_humana", False) print(f"[ROTEADOR_PLANEJADOR] Status: {status}, Schema preenchido: {bool(contexto)}") + if esperar: + print("[ROTEADOR_PLANEJADOR] Espera humana ativa → espera_humana") + return "espera_humana" + if not contexto: print("[ROTEADOR_PLANEJADOR] Schema vazio → esquema") return "esquema" - if status in ("pronto_codificacao", "revisando_estrategia", "aguardando_schema"): + if status in ("pronto_codificacao", "revisando_estrategia"): print("[ROTEADOR_PLANEJADOR] → agente_codigo") return "agente_codigo" @@ -60,5 +65,5 @@ def roteador_planejador(estado: EstadoTextToInsight) -> Literal["esquema", "agen return "fim" # Default: gera código - print("[ROTEADOR_PLANEJADOR] Default → agente_codigo") - return "agente_codigo" + print("[ROTEADOR_PLANEJADOR] Default → planejador") + return "planejador" diff --git a/src/state.py b/src/state.py index c079aee..89455c9 100644 --- a/src/state.py +++ b/src/state.py @@ -56,5 +56,7 @@ class EstadoTextToInsight(EstadoEntrada, total = False): saida_terminal: str feedback_critico: str status: StatusExecucao + espera_humana: bool + pergunta_ao_usuario: str tentativas_loop: int resposta_natural: str \ No newline at end of file diff --git a/tests/cassettes/test_integracao/test_estado_final_completo.yaml b/tests/cassettes/test_integracao/test_estado_final_completo.yaml index 8d8b544..fc6f433 100644 --- a/tests/cassettes/test_integracao/test_estado_final_completo.yaml +++ b/tests/cassettes/test_integracao/test_estado_final_completo.yaml @@ -1,4 +1,83 @@ 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: \"Qual o valor medio dos pedidos?\"\n- Schema + dispon\u00edvel: Sim\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: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '1549' + Content-Type: + - application/json + Host: + - generativelanguage.googleapis.com + User-Agent: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + x-goog-api-client: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2WRXU/CMBSG7/crll6DUQQJ3qqJXCCIi5E4Q47rYWsY7dKeGnTZf7fdGAxdk6V9 + z9vz8bQMwpAlILngQGjYbfjulDAs67+PKUkoyQVayYkFaDp5m6/s7J2FcO8vsTKW/hwzjokwoGIn + xqzQLq1aJ4qLjUgg8XqvdRaoUysJ1qDW1ljQ4nDLLVmxTp3quP/onbrTKkdfeqc45q29ag1sI6Qw + 2RLBKOltL9F8wY5RITnunXwZtAXq1MwaSHGGBI4THGn4QXYFRWqL8k7ZmtP11U2TrcP1zDCYHOKk + CPKz0Hgy7v1LbO5dWZF3gXfewk0JuaBvP0r08BaxDgn601fLIuggY5Qpm2Z03uNwNAgO0BqOr6iN + aICluHMI+4OLUX+Tg8nqikyjKZQ0OOXe8zSM5rDS8nH2ufwZTxdffbDmecuCKvgFSgPWCnMCAAA= + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Thu, 02 Apr 2026 14:59:06 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=2966 + 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 @@ -60,13 +139,13 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2WRXU/CMBSG7/crml5BAgYmROId8hVEAsJAjTGkoYetcWuXtjMi4b/bfVJ0F0tz - 3vd8PefkIIT3hFNGiQaF79G7iSB0yv6pJrgGro1QhkwwJlJfvPl3st7GouE7TcLr0dNo4KH+dlLT - QpNwJyQFufsiYQJ1NF4t5qhWeHKF0QZab+a1mBwj07h09tfoX36engcKu0KT1WKzRA9vVbk6tiY7 - V++PxmUfKUJIh40EhbC0n0sDPjDOVLACogTPdvIWS1ypjFP4NuGWUzbISuNEER/moIkhSyp+OJYi - irUnPoEPRJKR7XU7eTXrEleG216hZwiuJLfdKpOtympo+rLQvpF1PrMmCZk+prt4o1cPWyj0n8FK - GI7FDOtAJH6gr4dsu23XKbDlJLcgFcuR+RAZiE33pts8hEQFWUssQcWCK5jSDOvBG5L9bP6oX8Y/ - d9OlbM6o/9zBztn5BRkQnuGnAgAA + H4sIAAAAAAAC/2WRXU/CMBSG7/crml5BIoYPUaNXCEhIJCNsEI0xpNDDVt3aZe0MSPbf7T4KRXex + NOd9z9dzjg5CeEs4ZZQokPgBvesIQsfyX2iCK+BKCyakgwlJ1dlbfUfrrS0K9kUS9sYv46GPBqtJ + QwlForVIKaTrbxJl0ETPC3eGGrXHW84aCTnEup3RBx76l1UlVYHaLtFk4S7n6OmtjjPafMTWQPnp + /XF1XiMVERQzxoJCZOy5MeAd40yGCyBS8HIV353jk8o4hb0Otx3ToCyNM0kCmIEiGig5YcNJKuJE + +eIL+FBkJdD7/k1VzTrAhaFn9JLBhdRpd41oVZYj3ZdF9mmsq+k1ScTUodjFH7/62EKh/gxmYDgW + M6xCkQWhuhyy07t1amoVyBWkklXEAog1w1b3ut/aRUSGZUecgkwElzClhccVvku2Gz7dfiY/d9N5 + uPFGbBBgJ3d+AdJxKmKdAgAA headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -75,11 +154,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:42:24 GMT + - Thu, 02 Apr 2026 14:59:07 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=6709 + - gfet4t7; dur=1501 Transfer-Encoding: - chunked Vary: @@ -102,16 +181,15 @@ interactions: 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 ===\nQual o valor medio dos pedidos?\n\n=== SQL GERADA - ===\nSELECT AVG(total_order_value) FROM (SELECT order_id, SUM(payment_value) - AS total_order_value FROM order_payments GROUP BY order_id)\n\n=== RESULTADO - DA EXECU\u00c7\u00c3O ===\nStatus: exec_ok\nTotal de linhas: 1\nAmostra dos - resultados (primeiras linhas):\n[{''AVG(total_order_value)'': 160.99026669347316}]\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}}' + ===\nSELECT AVG(total_order_value) FROM (SELECT SUM(payment_value) AS total_order_value + FROM order_payments GROUP BY order_id);\n\n=== RESULTADO DA EXECU\u00c7\u00c3O + ===\nStatus: exec_ok\nTotal de linhas: 1\nAmostra dos resultados (primeiras + linhas):\n[{''AVG(total_order_value)'': 160.99026669347316}]\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: - '*/*' @@ -120,7 +198,7 @@ interactions: Connection: - keep-alive Content-Length: - - '1048' + - '1039' Content-Type: - application/json Host: @@ -134,15 +212,14 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2VSy27bMBC86ysWPCtG68DN4+ZGKmo0hRxHdYM+0DDSWiZKkQofRRLDH9NjfqFX - /1iXsuXIrQ4StTuc2Z3dVQTACq5KUXKHlp3DV4oArNp3yGnlUDlKdCEKNty4F+z2WfXOBHH4EC6x - eTpLk0mencN4Osvm4yT7pt6lafJ2fPGBYkD81kvH4frqEgRpmcYg/RbahG9N2ggcGjSVVxQvNXjr - N7+N0DEUXBZeUvUaNPziUhtw2nEJVCBR8DJcpNZ0YKCahQEKOX6HksOtNiWaHw1/DCL2FjAGrMFi - 5cmMGPQdNd5erTfPpSBptBZtqyDsADIw2JZOmM0z+HpXgfKEN6Kg6B9FnxjuPcKCPxG1cqGYQtfU - Rd0RD1jPuvX+/D1+MdxoicHNWpcoO/i6A7CFUMIuZ8itVgF2nWdTts8KVeIDhV9FnUBLzbzlFX4k - k2n0fD9g1hhdNy7XP1FdaN+Ofjgabtl6q3IAeHO6y7f2H6TOXh/H/xHbhGSF7O9Qb72oSy6Fewyt - 5OlNznpOuH/q6ryIepYxt9S+WrrDGkdnx9HOtK2PczRWbA2rsCYLj4aD0dFCcrtsFRnNt6H1xEkZ - MJ+mecK/fH4/mZ7op5PJtLoUN/dXpyxaR38BltTQRkYDAAA= + H4sIAAAAAAAC/2VSTW/TQBC9+1eM9uxEUFKl9BYSgyJAdlIrQgIOi3fiLKx3zX5UpVH+SznyO/LH + GNtx6oAP1mrmzZs3b2YfAbCCayEF9+jYLXymCMC+/Tc5oz1qT4k+RMGaW/+M7b794E0Qjw9NEdsk + 62SxzNNbmGXrdDNbpF/02yRZvJnN31MMiN8F5TncrT6ARVcbLRC4wJ+BC15Ra4TjE9Roy6AJJgwE + F46/rTQxFFwVQZF6QzzWoj8VGLjnyljwxnMFxFcQF3HQlAYwBqzAYRlo5hg4VMc/QhIzOoeuq0Q3 + htQ1ehptwjjY8se2SnvZdqtIR3Wu1YEeVhZ8zAY2HM7vr/GzedYobJypjEDVww89gG2llm63Ru6M + bmB3eZqxc1aSOw8UfhH1DVpqFhwv8SMZQGvk52Wx2pqq9rn5gXpuQrvGq8lNxzZY+wXg+uUp37p3 + kZpOX8X/EbsFtZVqeA+DU6EpuZL+VzNKnnzK2cAJ/4+u3otoYBnzOxPKnb/UOJlOopNpnY8btE52 + hpVYkYWjq/H1aKu427UdWXdaDpeiwWR0kVyMVu/w2/fH6TK7f53Nw+qGRYfoLw2rwCASAwAA headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -151,11 +228,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:42:28 GMT + - Thu, 02 Apr 2026 14:59:11 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=4091 + - gfet4t7; dur=2871 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_integracao/test_pergunta_com_ranking.yaml b/tests/cassettes/test_integracao/test_pergunta_com_ranking.yaml index 14af1af..cbe1763 100644 --- a/tests/cassettes/test_integracao/test_pergunta_com_ranking.yaml +++ b/tests/cassettes/test_integracao/test_pergunta_com_ranking.yaml @@ -1,4 +1,83 @@ 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: \"Quais sao as 5 categorias de produtos + mais vendidos por quantidade?\"\n- Schema dispon\u00edvel: Sim\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: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '1585' + Content-Type: + - application/json + Host: + - generativelanguage.googleapis.com + User-Agent: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + x-goog-api-client: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2WRa0vDMBSGv/dXlHzeZG66i1+9QAXZpkUFK+PYnLXBNinJKWyW/neTdt06baAk + 73lzLk8qz/dZDJILDoSG3fgfVvH9qvm7mJKEkmygk6xYgKaTt/2q3t5aCHfuEqsi6c4R4xgLAyqy + YsQKbdOqTay42IoYYqcPOmeBOiklwQbUpjQlaHG4ZZesWa9Ofdx/Dk7daZWhK50rjllnrzsD2wop + TPqMYJR0tpdwuWLHqJAcd1YeeV2BJjUrDST4hASWExxpuEHygkL1jfJWlQ2nyXjSZutxPTOMF4c4 + KYLsLLSYXg7+JTZ3tqzI+sB7b2GnhEzQ3o0S3r+HrEeC/vTVsfB6yBilqkxSOu9xOlp4B2gtx1fU + RrTAEswtwuH44nq4zcCkTUWm0RRKGgy48zwGsyXg8O2B9sHPLFjNv/L1fH3FvNr7BWhmkwNzAgAA + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Thu, 02 Apr 2026 15:41:27 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=3466 + 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 @@ -35,9 +114,9 @@ interactions: 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 ===\nQuais sao as 5 categorias - de produtos mais vendidas?\n\n\n\nResponda APENAS com a consulta SQL, sem markdown, - sem explica\u00e7\u00e3o."}], "role": "user"}], "safetySettings": [], "generationConfig": - {"temperature": 0.7, "candidateCount": 1}}' + de produtos mais vendidos por quantidade?\n\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: - '*/*' @@ -46,7 +125,7 @@ interactions: Connection: - keep-alive Content-Length: - - '3073' + - '3088' Content-Type: - application/json Host: @@ -60,14 +139,14 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/31S0U7CMBR931c0fdIECRAEYuKDbtPMwDZHMRhnSKUXaNxaXEuiEv7dbmMwNHEP - TXPP6Tn3nruthRCeU8E4oxoUvkIvpoLQtjhzTAoNQhugKpnimmb6yC2/be1uKBo+80d47A5dm8QC - IdJprjPJNnM9mxuvpcy+ZoKmEIu7KBghmTHIZlxDqtDNGJF2LDzfdyP0EHg+2r8soU4uF/iGc1Dk - DF3XHTiLxX0UTEJ0+/y/eRA5xqRk2cHEJ2cnsufIccd2LIbeyCPoEtem3B3ur41jNplMIB88lQyS - ir6rCHjBBVerCKiSosiHBCE+oFww+DTlllUZFNJ4o+gSRqCp2RI97AKbNtO1JvIdhC03xZYGvVap - VtvqCaHf3eNaapqcQO1Wu9f4o6wc48uT+r5rv4IZkyZcf+WzEHdKcC0K/auxKgyrlhnWK7lZrvRp - k4OOtQ+tzPEJMsXLwJaQmggvOs3Li0VC1aowxBmotRQKPJZz/JA49LnX9cK+/O574fsbn348DrC1 - s34Augg2yfECAAA= + H4sIAAAAAAAC/31SXWvCMBR9768Iedpgig51MtjDVutQ1Lhaxz46JJirBtuka1KmiP99abUa97A8 + hHDPuefee252DkJ4TgXjjGpQ+B59mghCu+LOMSk0CG2AMmSCCU31mXs4O+ttKBo2eRKeeAPPDUKB + UFJNUsmyuZ7NTamlTLczQWMIRdcnQyRTBumMa4gVepwgyUPRJ70ROuYUwSSXISMDnqQ4Qw+WMmeh + ePbJdIye3v+tSfyO5x9JLpmOgqsL0WvU8SZuKAa9YS9ATWzNtj+9v27OjqQygnzcWDKISvq+JOAF + F1ytfKBKisKVgIzxCeWCwcaEa05ZoJDGmaJLGIKmZjf0tAFs2owTHcg1CFdmxW7arfpBzdrlBaHV + OuJaahpdQPVavUy2lFXH1OWRvWXrA5gxacT1Np8l8N4CbFmh/zRWmuFYnmG9ktlypS+bbDeco2kH + H18hVfxg2BJiY2HlttqsLCKqVkVBnIJKpFDQYzmn37gj9CMjRHa631qN44W7/nlpYGfv/AJstiMM + 5wIAAA== headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -76,11 +155,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:41:57 GMT + - Thu, 02 Apr 2026 15:41:29 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=1633 + - gfet4t7; dur=1451 Transfer-Encoding: - chunked Vary: @@ -102,20 +181,20 @@ interactions: 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 ===\nQuais sao as 5 categorias de produtos mais vendidas?\n\n=== - SQL GERADA ===\nSELECT\n T2.product_category_name\nFROM order_items AS T1\nINNER - JOIN products AS T2\n ON T1.product_id = T2.product_id\nGROUP BY\n T2.product_category_name\nORDER - BY\n COUNT(T1.product_id) DESC\nLIMIT 5\n\n=== RESULTADO DA EXECU\u00c7\u00c3O - ===\nStatus: exec_ok\nTotal de linhas: 5\nAmostra dos resultados (primeiras - linhas):\n[{''product_category_name'': ''cama_mesa_banho''}, {''product_category_name'': - ''beleza_saude''}, {''product_category_name'': ''esporte_lazer''}, {''product_category_name'': - ''moveis_decoracao''}, {''product_category_name'': ''informatica_acessorios''}]\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}}' + PERGUNTA DO USU\u00c1RIO ===\nQuais sao as 5 categorias de produtos mais vendidos + por quantidade?\n\n=== SQL GERADA ===\nSELECT\n p.product_category_name\nFROM + order_items AS oi\nJOIN products AS p\n ON oi.product_id = p.product_id\nGROUP + BY\n p.product_category_name\nORDER BY\n COUNT(oi.product_id) DESC\nLIMIT + 5\n\n=== RESULTADO DA EXECU\u00c7\u00c3O ===\nStatus: exec_ok\nTotal de linhas: + 5\nAmostra dos resultados (primeiras linhas):\n[{''product_category_name'': + ''cama_mesa_banho''}, {''product_category_name'': ''beleza_saude''}, {''product_category_name'': + ''esporte_lazer''}, {''product_category_name'': ''moveis_decoracao''}, {''product_category_name'': + ''informatica_acessorios''}]\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: - '*/*' @@ -124,7 +203,7 @@ interactions: Connection: - keep-alive Content-Length: - - '1330' + - '1335' Content-Type: - application/json Host: @@ -138,15 +217,15 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2VSS27bQAzd6xTErB3Djesazc6NFMDoR44jGAXaophYtDStNBRmRkFaw3dp0EUP - 0CPoYuVIka2kXhgj8vE98pH7AEBspU5VKh1acQGfOAKwb/99jrRD7TjRhzhYSeNO2O63H7wZ4vDe - F4lNtI7CZRJfwGK1jjeLMP6sr6IofLO4fMsxYH5bF07CzfU7MGgr0ily1Bh0smRlhOYXVGiyWjMq - Jaht3TwYRSNQKefVTvn+CaSFGWx5ioyM4g+mqQyltSMLpVQW7rAd0zJ7CbfSImjp9Z3MsPRwxZPa - McTWN+KbSrl0R0bjlgG+8MTOnaKRzDYC5ml+E+TNA6AxXFI0fzO15QfVLS0PYSo/T/PHA1lV3pJJ - vexYDGw7HN9fRiezDRXonSwpxaKHH3qA2CmtbL5GaUl72E0Sr8Qxq9jNew5Pgl6gpRa1ZfX33BKv - XR6XK9iwsnIJfUd9SXW79un0dcc2OJMngFfTx7wjJ4snqReT+Wz0H7MNWVcVwwMa3BaPKQvlfvhZ - kuhjIgZWuGeN9WYEA8+Ey6nOcvesyfk0eHStM3KDxqrOMV4De3h2Pp6d7Qpp81ZRdLdocZl6zIe7 - JJQypKuvq+uf8+XqPP6Gk8VLERyCf4WOOSVDAwAA + H4sIAAAAAAAC/11S227TQBB991eM9rmNmlZRQ99CYqRQkNPEikDAw5CdJAv2rtlLVIjyL0U88R35 + MWbtOnWwZGs958zMmbOzTwDECrVUEj05cQefOAKwr78RM9qT9gy0IQ5WaP0Lt3n2nTNTPD3GJLFM + 5+lkmmd3MJrNs+Vokn3Wb9J08no0vucYcH0XCo+weHgHllxltCSOWkseS+5McHyCiuwmaGZJA8GF + 42+rzAUoybhaq6jfADoYwIqn2Bir+IfLVNbI4I2DEpWDHdVjOqiMhR8BOVcis9Bb3B3/cgZGOR43 + VJ5la34Vu9DUJC5iepC5KDdKlwy74x/DyWSjYgdctGKUf7CMwjqyeESyyDJ6ouPY4XT+cvHiszUF + RRNLI6lo6YeWINZKK7edEzqjI22RZzNxQhUb+cjhq6RtUJcWwfGA79ldvnE83avgacvK5+Y76bEJ + 9Y3f3Fw31TobckYYDJ9xbzwWZ1B/cNWCncpuwn1V0d2dzlrxmFgo/zPOkqcfctGxwv8nrDUj6Xgm + /NaEzdafi+z3+8Pk2bbGySVZpxrL+KrZxMvr3uByXaDb1i1Fs4eOpjJy7j/eZrhahLfh67dft9PZ + 7tVsHB6GIjkk/wASEdTJPwMAAA== headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -155,11 +234,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:42:02 GMT + - Thu, 02 Apr 2026 15:41:35 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=4015 + - gfet4t7; dur=5972 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_integracao/test_pergunta_simples.yaml b/tests/cassettes/test_integracao/test_pergunta_simples.yaml index 0716985..184e1a8 100644 --- a/tests/cassettes/test_integracao/test_pergunta_simples.yaml +++ b/tests/cassettes/test_integracao/test_pergunta_simples.yaml @@ -1,4 +1,83 @@ 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: \"Quantos pedidos existem no banco?\"\n- + Schema dispon\u00edvel: Sim\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: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '1551' + Content-Type: + - application/json + Host: + - generativelanguage.googleapis.com + User-Agent: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + x-goog-api-client: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/12RUU/CMBSF3/crlj6DAUQIvgoxPBhQp9E4s9ysl61xtEt7Z9Bl/912Y1DckqU9 + 5/Te3m91EIYsBckFB0LDbsMPq4Rh3X6dpyShJGv0khVL0HTOdk/trW2E8OAOsTqWbh8zjqkwoGIr + xqzUtqxKUsXFTqSQOn3QJ0vUWSUJElBJZSrQ4njKvrJhXp/mtP4cnG+nVYGu9V5xLPp40wfYTkhh + 8icEo6SLPUebLTu5QnI8WHkU9A3a0qwykOEDElhOcKLhBtmXFKkvlHeqajldj2ddNY/rRWCyOPqk + CIoLaz7rPa+wWdq2ovCBe//CTgmFoB83SrR6i5hHgv7dq2cReMgY5arKcrq843QyDY7QOo6vqI3o + gGW4twiHk6ub4a4Ak7cdmUZTKmlwzV1m+R5tAJLxavH98jtfbxfDezN6VCxogj/pnKO9cwIAAA== + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Thu, 02 Apr 2026 14:58:23 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=2624 + 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 @@ -60,12 +139,12 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2WQUUvDMBSF3/srQp4UVpnFwfRN2oqKc2OLIohIWO7aYJvU5k4npf/dtF23VPMQ - kntO7sn9Ko8QuuZKSMERDL0ir7ZCSNXujaYVgkIr9CVbLHiJR2+3KudsLQi75hFdxQ9xyEg4f3pk - J7oUUL5LcUpulvMZaa+GOg/rw/ltdIwrdQZNr1wLyHp73RvoRipp0iVwo1UbyeYLelClErCz5bHX - B7St6dbwBGaA3A7OD+PRotR5gUx/gAr1th18OrnoujmgBobLvYwaeTZUgvPRv74msqkycwE6bO2Q - PJP400zC4hdGHRD451s9Cs8hRjHV2yTF4RcnU2+PrKP4bMHLDlcCuQXoB2cTf5Nxk7aBtARTaGXg - TjSe2y8W8Q1bR8aHTzSL8X2wSq6/qVd7v0+rnsJCAgAA + H4sIAAAAAAAC/2WQUUvDMBSF3/srQp4UVpHhsPNNaoWCs3NmoohINHdtNEtKcyvTsf9u2q5bpn0o + yb3n3pPzrQNC6DvXQgqOYOkFeXYVQtbtv+kZjaDRNfqSK5a8wr22+9be2UkQVs0QvU9ukpiROJvf + siNTCahepTgm17NsQtqrpd7gZnd+GeztKqOg2bU0AlQv3/QCupBa2mIG3BrdWrJsSnddqQWsXPk0 + 6A3a1bS2PIcJIHfB+S4eLSuzLJGZT9Cxqdvg0eis2+aBOhCMt200yNXhaBQN/u21V85VKh+gx9aF + 5Erid5OEJY+MeiDwz7N6FIFHjGJh6rzAwycOR8EWWUfxwYGXHa4clg5gODwZhQvFbdEa0gpsabSF + VDSaJGUZfwrVZfj28XOeTr/G07i+i2iwCX4BO0FHyUICAAA= headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -74,11 +153,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:41:38 GMT + - Thu, 02 Apr 2026 14:58:24 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=4384 + - gfet4t7; dur=839 Transfer-Encoding: - chunked Vary: @@ -131,15 +210,14 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2VSwW7bMAy9+ysInZMgaNd26C2rPcBYA6epEQzohkWtWFubLRmSPHQL8i8tdugH - 9Narf2yUPSdOl0NAk0+Pj4/cBADsjishBXdo2TncUAZg0/77mlYOlaNCn6JkxY3bY7vfZhATxOGD - f8RW0TIK4zQ5h9limaxmYfJFfYyi8MPs4hPlgPhtXTgO11eXkKHhgoNBW2klEIQ06HhJ/RGaR6jQ - ZLUirNBQ27p5MlKPPIOjATQFZg/XoJrXEo0G4llrI9B8k2INioPjt1jwf0m7nkBifUuvQmgLtvmj - W1nS+snR0kcJfK8UiZogefMEaAy9KJqXTN5RoGvfTdIjU3kpzTMBJ2zgzHYXfx3t/TS6QG9WqQUW - PXzbA9i9VNLmS+RWKw+7TpMF21UlOfVA6WnQN2ipWW15hnNSQZvlu/2xyuiycqn+gepC1+1mj6bv - O7bBJRwATvq6044XB6XT6enoP2IbUltZDE9kcD00JS+k++VHSaPPKRs44d7o6r0IBpYxl+s6y92h - xuN33oDWtM7HFS1XdoZlWJKF46PJyfi+4DZvO7LuzCzGwmPin2nI8Sy+nB9//30WL8ZzlWdXcxZs - g7+8QGIFJQMAAA== + H4sIAAAAAAAC/2VSwU7bQBC9+ytGe04iQLQp3AJ2pUArh2BFlSgSUzyxLewda3ctAVH+pVX/otf8 + WGftOjHUB2s08/bNvDezCQDUI+q0SNGRVedwJxmATfv3NdaOtJNCn5JkjcYdsN23GcQCcfTsH6lV + tIzCeRKfw2yxjFezMP6uP0dReDG7vJYcCL9tSodwe/MFMjKYIhiyNeuUIC0MOaykP8HuJ9RkskYL + NmVobLP7ZQoeeQYnAlgCc4Az6N2figyD8NQk+tiCRnD4g0qEBzYpGfswgdj6fn4Ej7C730JUopGY + YI2vVIEVQnk+gjUbTY/kezE4dliCTOpn5okayN/u4/vRwTTDJXlHKk6p7OHbHqDWhS5sviS0rD3s + NokXal8txI5nSR8FfYOWWjUWM/oqqmV9uF+Sqg1XtUv4ifQlN+36To4+dWyDdb8BnJ79q7fK3pSm + x9PRf8Q2lLZFObyDwYmISiwL9+KlJNG3RA2ccO/m6r0IBpYpl3OT5e7djB+9Aa1pnY8rWWLRGZZR + JRaOTyYfxusSbd52VN0tWZqnHhPJJSK+nMbjMH+dzhdnV01d3TypYBv8BcYwIfgKAwAA headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -148,11 +226,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:41:41 GMT + - Thu, 02 Apr 2026 14:58:27 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=2611 + - gfet4t7; dur=2951 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_nodes/test_cadeia_code_agent_executor.yaml b/tests/cassettes/test_nodes/test_cadeia_code_agent_executor.yaml index bd33fc3..2b27042 100644 --- a/tests/cassettes/test_nodes/test_cadeia_code_agent_executor.yaml +++ b/tests/cassettes/test_nodes/test_cadeia_code_agent_executor.yaml @@ -60,12 +60,12 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2WQUUvDMBSF3/srQp4UNnHinPraTRi4tc5MBJERl7s2mCaluYVp2X83be2Wah9K - uOfknpyvCgihW66FFBzB0nvy5iaEVM2/1oxG0OiEbuSGOS/w5G2/yjs7C8K+vkSfZ4+zkJEwWi/Z - 2ba0aDIoNlKck4dVtCDdxFLv+uF4fh+cQgujoN6YGQGqsx86A91JLW26Am6NboJZFNOjKrWAvRtf - Bl1As5qWliewAOSuPj+WpHlhshyZ+QQdmrKpfzu+brd5uHqGu18ZDXLVVyaTwb+9dupSpfIxeoRd - Sa4kftVN2OyVUQ8E/nlWhyLwiFFMTZmk2H/iaFSbG2YtxhdHXra8EsgcweHVxXi4U9ymTSItwOZG - W5iL2qM2bMpFbJcfN+n3ZB7nCDx5mtPgEPwAbPBO20kCAAA= + H4sIAAAAAAAC/2WQUUvDMBSF3/srQp4UNnGdk+mbzAoDZ8eM4hSRsN61cWnSNbcwGf3vpu3aZdqH + Eu45uSfn23uE0BVXkYg4gqG35MNOCNnX/0rTCkGhFdqRHWY8x6O3+fbO2VoQdtUl+hw8BhNGJuHL + EztbFQZ1CvmXiM7JwyKckXZiqHO97M6fvWNoriVUG1MdgWztZWuga6GESRbAjVZ1MAvntFOFimBn + x5deG1CvpoXhMcwAua3Pu5I0y3WaIdMbUBNd1PXHo6tmm4PrxHBzkFEjl6fKcND7t9fc21QhXYwO + YVuSS4E/VRMWvDHqgMA/z2pReA4xioku4gRPn3g99g7IGoqvFrxocMWQWoB9/2LUX0tukjqQ5mAy + rQxMo8rzvWQhB/99uh2Ot2jmAzPc+HdL6pXeLzMSokhIAgAA headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -74,11 +74,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:43:37 GMT + - Thu, 02 Apr 2026 15:00:31 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=1797 + - gfet4t7; dur=2591 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_nodes/test_code_agent_com_feedback_regenera.yaml b/tests/cassettes/test_nodes/test_code_agent_com_feedback_regenera.yaml index 9903870..9f8874a 100644 --- a/tests/cassettes/test_nodes/test_code_agent_com_feedback_regenera.yaml +++ b/tests/cassettes/test_nodes/test_code_agent_com_feedback_regenera.yaml @@ -63,14 +63,14 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/31SXU+DMBR951c0fdJEl0HmZ+KDAjOog8k6oxFDmvVua4R2oV3iXPbfLTA2pok8 - NM09p+fcey5rCyE8oYJxRjUofI3eTQWhdXWWmBQahDZAUzLFBS30nlt/69bdUDR8lY/wyH/yXZII - hIjTWRSSLSc6nRivmSxWqaA5JKIfRwMkCwZFyjXkCt2OELETEYShH6OHKAjR9mUNOaVcFBrOTpEz - dNN24CwR93E0HqK7t//No9gzJjXLjcYhOTqQPUaeP3IT8RQMAoLOcGvKze7+cbLPppAZlIPnkkHW - 0DcNAU+54GoeA1VSVPmQaIh3KBcMvky5azUGlTReKjqDAWhqtkR3u8CmzXyhifwE4cpltaUr+7xW - a231gHDR2+JaapodQLbTvTz5o6w848uz9r5bv4IZk2Zcr8pZiP9KcCsK/auxJgyrlRnWc7mczfVh - k459aW1Tq4N8gULxOrEZ5CbDU6dzdjrNqJpXjrgAtZBCQcBKDkyJR+nqsR/l+vsiGKape86fe9ja - WD+8WULx8gIAAA== + H4sIAAAAAAAC/3VS0UrDMBR971dc8qSgYotTFHzQrkrVtbOLMrEywnK3BdtkNBk4x/7dtF23TrEP + Idxzcs6953blAJAxk1xwZlCTK3i3FYBVdZaYkgalsUBTssU5K8yOW3+r1t1SDH6Vj8ggeAp8CtQ7 + mReKL8ZmNLZGU1UsR5LlmMq7JO6BKjgWI2Ew13AzAOqmMoyiIIGHOIxg87KGvFQCxJHlbBUFh+u2 + g+CpvE/ilz7cvpXs/83jpGtNapYfv0T0wMrumrFKh9ANBn4qn8JeSKFDWlOut/ePo102hcqwHDxX + HLOGvm4IZCKk0LMEmVayyofGfbJFheT4ZcunTmNQSZOFZlPsoWF2S2y7C2KnyueGqk+UvlpUW7p0 + z2u11lb3CBdnG9wow7I9yPXO3KM/yrprfUXW3nfrV7BjskyYZTkLDYaUtKIwvxprwnBamREzU4vp + zOw36XVcZ5NaHeQrFlrUiU0xtxkeeyed40nG9KxyJAXquZIaQ15yJorGDIc8eDz3vy/C/lIP2efz + mDhr5wdChv7p8gIAAA== headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -79,11 +79,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:43:07 GMT + - Thu, 02 Apr 2026 15:00:16 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=2293 + - gfet4t7; dur=2332 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_nodes/test_code_agent_gera_sql.yaml b/tests/cassettes/test_nodes/test_code_agent_gera_sql.yaml index 22e35c9..b7f1101 100644 --- a/tests/cassettes/test_nodes/test_code_agent_gera_sql.yaml +++ b/tests/cassettes/test_nodes/test_code_agent_gera_sql.yaml @@ -60,12 +60,12 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2WQX0vDMBTF3/spQp4UNpHuD+prV0FwTmYUQWSE5a4NpklN7tjm2Hc3bdcu0zyE - 5J6Te3J/+4gQuuRaSMERHL0jH75CyL7eK81oBI1eaEu+WHKLJ2+z9sHZWxC21SP6kj6mCSPJ7PWJ - XRgrwC6kuCT389mU1FdHg4eH7vzZO8VZo6DqVRgBqrUfWgNdSS1dPgfujK4j2eyZdqrUAra+fB21 - AXVrunY8gykg94PzbjxaWlOUyMwX6MSs68FvRsOmWwDqzHB7lNEgV+dKPO796+smPlWqEGDA1g/J - lcRdNQlL3xkNQOCfb7UoooAYxdyssxzPvzgeREdkDcU3D142uDIoPMB+fDXqrxR3eR1ILbjSaAcP - ovIsF2zC+XxgRPwdb/q7vPwZJhsaHaJfeLzepEECAAA= + H4sIAAAAAAAC/12QUUvDMBSF3/srQp4UnOjcnPomtbLhZseMIopIWO7WYJeU5BbmSv+7abt2mX0o + yT3n3pP7FQEhdMmVkIIjWHpHPl2FkKL+V5pWCAqd0JZcMeMGD97mK7yzsyBsqyb6Ek2jkJEwfn1m + J9oIMN9SnJLHRTwj9dVSr7Hszl9nhzijU6hmbbSAtLWXrYGupJI2WQC3WtWRLJ7TTpVKwNaVL4I2 + oB5Nc8vXMAPkbnHerUczozcZMv0DKtR5vfjNcNBM80AdGW73Mmrk6bFy1bZ6c+2DS5WpD9Bj65bk + qcTfahMWvTPqgcB/z2pRBB4xionO1wkeP3F0GeyRNRTfHHjZ4FrDxgHs9c+HvVXKbVIHUgM208rC + RFQeMWAxX4bjp+k4240m8+uPXd6/H9CgDP4AIW84WEICAAA= headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -74,11 +74,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:42:59 GMT + - Thu, 02 Apr 2026 15:00:08 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=1126 + - gfet4t7; dur=1422 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_nodes/test_critic_avalia_resultado_correto.yaml b/tests/cassettes/test_nodes/test_critic_avalia_resultado_correto.yaml index 3e1f309..41d0f1f 100644 --- a/tests/cassettes/test_nodes/test_critic_avalia_resultado_correto.yaml +++ b/tests/cassettes/test_nodes/test_critic_avalia_resultado_correto.yaml @@ -34,10 +34,15 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/y2OuwrDMAxF93yF8FxCoXTplkKHQiiFPnaRCMfUdoJkQ03Iv1eh2XTOvYI7VwCG - mEc2J5gVFLuxJ6Xj/rD7i0AiaFdnnoMTCFrwoEeXmSkmX4C+E7Gj2LloYXB2gJ4Cxr6Gx+Q+JODi - ZgCZIEtGr2+JwjQycqnh7gmFIHEBtKh1j4m4NtsGSZiyrBNet+bdXNvm3F6MZku1VD+9N5M8xgAA - AA== + H4sIAAAAAAAC/11SwW7TQBC9+ytGeymgNGqjFNrcQuJKEW0dEhOBAJElntgr7F2zu1ZLo/xLqx74 + AG5c/WPM2nHq4IM1O+/tm5m3s/EA2IrLSETcomED+EwZgE31d5iSFqUloElRMufaPnPrb9OKiWLx + zl1iC3/mjydhMIDhdBYshuPgi7z0/fHb4egd5YD0TZFaDvP3V7Cc+1f+KIRR8OEmfPHqJVzOgmtQ + OkJtlqDR5EpGCJHQaHlGbSGUD5CjjgtJEpGCwhTloxaq44QtzaVAgSz/ZqgVWGV5CiSQI82rDEgO + ln/HlMNyV6QLgXGFXEuOwXM6UCF36MByc1RpfNsJHA3g4qLfP90uO2DKJwWrlGtHXPN7zMBdJBog + dUBgUj4CasIhLf/EYkWBKlw7ggbRuZup/E3ELmtZud3HXzvPD6BVis7dTEWYNvRtQ2BrIYVJZsiN + ko42D4Mp26OCPLyj9InXFKikWWF4jNfUBa0C3z84y7XKchuqHyhHqqhWoXdyXqu1VueA8OZsh1d2 + HUD91w3WEjZjKivS9k611o2m5Kmwv9woof8xZC0n7H99NV54LcuYTVQRJ/awx9PznrczrfZxQQsg + asNizMjC41737HidcpNUFVm9gAYnkeMkn8KAr/jsZlIMf1ozjaf3o9uhYt7W+wfW/ferVgMAAA== headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -46,85 +51,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:43:19 GMT + - Thu, 02 Apr 2026 15:00:24 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=6581 - 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: 503 - message: Service Unavailable -- 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 pedidos existem no banco?\n\n=== SQL GERADA - ===\nSELECT COUNT(*) as total_pedidos FROM orders\n\n=== RESULTADO DA EXECU\u00c7\u00c3O - ===\nStatus: exec_ok\nTotal de linhas: 1\nAmostra dos resultados (primeiras - linhas):\n[{''total_pedidos'': 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: - - gzip, deflate, zstd - Connection: - - keep-alive - Content-Length: - - '941' - Content-Type: - - application/json - Host: - - generativelanguage.googleapis.com - User-Agent: - - google-genai-sdk/1.68.0 gl-python/3.10.12 - x-goog-api-client: - - google-genai-sdk/1.68.0 gl-python/3.10.12 - method: POST - uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent - response: - body: - string: !!binary | - H4sIAAAAAAAC/2VSTW/aQBC9+1eM9pILoCgKEOVGY1eiXxBioUptD1M8wKr2rrMfEgXxXxL1UPXc - W6/+Y501mJjWB2s0H++9eTu7CEAsUGUyQ0dW3MInzgDs6n+oaeVIOS40KU6WaNxL7+HbtWJucbQJ - Q2KezJJ4nE5uYTSdTeajePJZvU6S+NXo7i3ngPGtzx3Cw/07qH5BSWZJ0mHBpASY0aPHDIEZETBU - V15xd6bBW189G6k7AcPxCpoDY6iZ1aCqPwUZDU47zCEjHuc9tQWF4PAr5QgX2mRk7EUPJmCoVsI4 - TEgbWvjqZ/VDB1WLHA0TZZLhNRAscXtEtcwla+pCB4RSW5ZXPcGjJ+t4vAcfAsi6egYyhsnz6vdK - LjjQPmiSrNWUQXbN1hMtH/en+EvnxX2jcwrWFjqjvGnfNw1iKZW06xmh1Sq0PaSTqThVpcpow+nL - qCGooYW3uKL3rILvAE+vLUqji9Kl+hupO+3rO7i6vDmgte7mrGHQP9Zrh85KN9fDzn/ANmZambcP - qnVrvCXm0n0Pq6TJx1S0nHD/6Gq8iFqWCbfWfrV25xr7w+voaNrBxzlfgTwYtqKCLexe9frdZY52 - XTOK+m2VpXEWeuQ0jRElvumS2w7H0+3A+8F9IqJ99Bez6SP2UwMAAA== - headers: - Alt-Svc: - - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 - Content-Encoding: - - gzip - Content-Type: - - application/json; charset=UTF-8 - Date: - - Wed, 25 Mar 2026 14:43:30 GMT - Server: - - scaffolding on HTTPServer2 - Server-Timing: - - gfet4t7; dur=10003 + - gfet4t7; dur=2416 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_nodes/test_planner_com_feedback_revisa.yaml b/tests/cassettes/test_nodes/test_planner_com_feedback_revisa.yaml index c93fe4d..92e4722 100644 --- a/tests/cassettes/test_nodes/test_planner_com_feedback_revisa.yaml +++ b/tests/cassettes/test_nodes/test_planner_com_feedback_revisa.yaml @@ -6,12 +6,18 @@ interactions: atual:\n- Pergunta do usu\u00e1rio: \"Quantos pedidos existem no banco?\"\n- Schema dispon\u00edvel: Sim\n- Feedback do cr\u00edtico: A SQL retornou dados incorretos, faltou filtrar por status.\n- Tentativas realizadas: 1\n- Status - atual: reprovado\n- Erro anterior: Nenhum\n\nDecida a pr\u00f3xima a\u00e7\u00e3o - respondendo com EXATAMENTE uma das op\u00e7\u00f5es abaixo:\n- \"pronto_codificacao\" - \u2192 se temos schema e devemos gerar/regenerar SQL\n- \"revisando_estrategia\" + atual: reprovado\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- - \"aprovado\" \u2192 se o resultado j\u00e1 foi aprovado pelo cr\u00edtico\n\nResponda - APENAS com uma das op\u00e7\u00f5es acima, sem explica\u00e7\u00e3o."}], "role": + \"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: @@ -22,7 +28,7 @@ interactions: Connection: - keep-alive Content-Length: - - '1045' + - '1600' Content-Type: - application/json Host: @@ -36,12 +42,15 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2WQQU/DMAyF7/kVVc4b2qAwwQ0xDjvAxqgQ0oSQ1bhtRJtUjTdtVP3vJC3tUsgh - ivxebL+vZkHAY1BCCiA0/C7Y2UoQ1O3tNK0IFVmhL9liCRWdvd2pvbe1EB7dJ15WtoH+jLWQiYwh - Bs09YzO8Pybn9pXO0f0ttMC8tze9gSdSSZNtEYxWzvYarTd8UKUSeLTlGesHtK353kCKT0hgg8IQ - x+1XlBTpL1QPet8Gnd/edN08MCNDL5MmyMdKeDX519cs7VSZ+8A8ljYk5JJOLkn0+B5xDwT9WatH - wTxinDK9TzMarxiGc/bLrMP4hpWRHa8UC0twenlxPU1yMFk7kVdoSq0MroTzwCFaQnJaPK8PL9+L - 1Uah2hb3M84a9gNSojPHMwIAAA== + H4sIAAAAAAAC/2VSzY4TMQy+z1NEuRSkdtXuj4q4IAQcFoG6gmqFYNDKzLjTQBoPiVMVqr4QUp+i + L4Yz0+lOYQ4Zx/7iz/7sbaaULsCVpgTGoJ+rL+JRatucKUaO0bEEOpc4a/D8iG2/bc8WCOMmPdLb + 3KV7rkssTADKxZlrhwWGYBge4HssIdfDDlajr6JLAXqIIYI3xyf3VBz+qIoCiw9UiWphLHvwioKq + URpIf/IKbBVXSmAcg8JQY3HYL0xB6kmK4gZXtaWhGgCbNYWBWNJjYeNhX7bXGl0pLWMYPH2hZqrw + hz2n90ZUKiiqn1G4wbKYQtRUQQ3zkdOBkowhWgYFkkda8Be5zt1O9zTaneyvw0dlPVlMsq2oRNvB + dx1AL4wzYfkBIZBLsI/z2Z0+RaVA3Ih7nHUETWodA1T4HhlkxnCapK49rWqe0w90ryg2M766fNZm + 6+3EGWA6PsaZGOxZaHI9ngz/yxxeC6+x/W3pLZK0Cdbwr9TL/M2nue5Jwf8U1omR9TTTvKRYLfm8 + yMl4fJUdZWuVvEcfTCtZhSsRcXR5cTNaWAjLhlJ7WRSZGd6WCQPX8xl8G719R+vq9/T27mY9/Uwv + SWe77C/uqLKcMQMAAA== headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -50,11 +59,92 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:42:53 GMT + - Thu, 02 Apr 2026 15:00:02 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=2955 + - gfet4t7; dur=6709 + 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 pedidos existem no banco?\"\n- + Schema dispon\u00edvel: Sim\n- Feedback do cr\u00edtico: A SQL retornou dados + incorretos, faltou filtrar por status.\n- Tentativas realizadas: 1\n- Status + atual: reprovado\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: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '1600' + Content-Type: + - application/json + Host: + - generativelanguage.googleapis.com + User-Agent: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + x-goog-api-client: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2VR22obMRB9368QekkLdkkcpwl9KaUNNIWS0JrQ0i1mkMZrpVqN0AXcGv9QIF/h + H+to7XXW7T5oR3POaGbOWVdCSAVOGw0Jo3wjfnBGiHV3FoxcQpcY6FOc9BDSM3f3rQcxUxKuSpFc + 167ca6lRmQhUc7KWDhXGaBLM4SFrqOWop3kMTXYFoHmOGYLZl9yT2j6KhmLiHAiNoowGQVAUHnl+ + /itqBdgmt4JZKUeB0aPaPi2MIvHCUxC4wtZbGokTrlY2b580nfDNo9O8JpaY5VBogYGXb2tZu40c + rLY5xD9Hz4IEsli2bUmj7embniAXxpm4/IIQyRXa19ntnTyghluvOH1a9Q26p2WO0OBnTMDWwMEA + 6QO1Ps3oF7r3lDtrzidXu9cGVh4RLl7v8UQJ7BF0Np1cjv57OX7gvsYOTR74z2uCNel32WV2/W0m + B1KkfwbrxagGmsm0pNws0/GQZ6fT82ov207JewzR7CRrsGURx5NXF+OFhbjsWsrABpOLeKM7WacP + t/B9Hj5puP5zeXM3nn9MV++mstpUfwFeJnCn6AIAAA== + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Thu, 02 Apr 2026 15:16:33 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=6547 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_nodes/test_planner_com_schema_decide_codificar.yaml b/tests/cassettes/test_nodes/test_planner_com_schema_decide_codificar.yaml index 8205ff4..ae96d0c 100644 --- a/tests/cassettes/test_nodes/test_planner_com_schema_decide_codificar.yaml +++ b/tests/cassettes/test_nodes/test_planner_com_schema_decide_codificar.yaml @@ -5,14 +5,21 @@ interactions: situa\u00e7\u00e3o atual e decidir a pr\u00f3xima a\u00e7\u00e3o.\n\nContexto atual:\n- Pergunta do usu\u00e1rio: \"Quantos pedidos existem no banco?\"\n- Schema dispon\u00edvel: Sim\n- Feedback do cr\u00edtico: Nenhum\n- Tentativas - realizadas: 0\n- Status atual: schema_obtido\n- Erro anterior: Nenhum\n\nDecida - a pr\u00f3xima a\u00e7\u00e3o respondendo com EXATAMENTE uma das op\u00e7\u00f5es - abaixo:\n- \"pronto_codificacao\" \u2192 se temos schema e devemos gerar/regenerar - SQL\n- \"revisando_estrategia\" \u2192 se o cr\u00edtico reprovou e devemos - tentar uma abordagem diferente\n- \"aprovado\" \u2192 se o resultado j\u00e1 - foi aprovado pelo cr\u00edtico\n\nResponda APENAS com uma das op\u00e7\u00f5es - acima, sem explica\u00e7\u00e3o."}], "role": "user"}], "safetySettings": [], - "generationConfig": {"temperature": 0.7, "candidateCount": 1}}' + 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: - '*/*' @@ -21,7 +28,7 @@ interactions: Connection: - keep-alive Content-Length: - - '996' + - '1551' Content-Type: - application/json Host: @@ -35,12 +42,12 @@ interactions: response: body: string: !!binary | - H4sIAAAAAAAC/2WQ0U+DMBDG3/krSJ83o4i6+LoZs8TpVGKmxiwnHNAILaGHTgn/uy0MVrQPTXPf - 17v7frXjuiwEEfEICBW7dF91xXXr9jaaFISCtNCXdLGAkg7e7tTWW1sId+YTK0rdQG5DGfGYhxCC - ZJaxGd5vk0P7UmZo/uYywqy3N72BxVxwlT4gKCmM7TG4W7NB5SLCnS4fO/2AtjWrFCS4QgIdFIY4 - Zr+8oEB+oJjLqg16MvO7bhaYkeF8L5MkyEaKf+pN/vVVCz2VZzYwi6UOCRmnb5MkuNoEzAJBf9bq - UTgWMUaprJKUxit6vufsmXUYn7BUvOOVYK4JTr2js2mcgUrbiaxEVUihcBkZz/M2WMBL9XW7ml// - XCzXs8/3m/x+xpzG+QXmZT5XMwIAAA== + H4sIAAAAAAAC/2WRXUvDMBSG7/srSq436Sbb0Fv1YqJsapGJlXFoztqwNinJKUxL/7tJu26ZNlCS + 97w5H0+aIAxZCpILDoSG3YafVgnDpvu7mJKEkmxgkKxYgaazt/8ab28thAd3iTWJdOeEcUyFAZVY + MWGVtmnVNlVc7EQKqdNHg7NCndWSYAtqW5satDjesku2zKvTnvZfo3N3WhXoSpeKYzHY28HAdkIK + k78iGCWd7S1erdkpKiTHg5WjYCjQpWa1gQyfkcByghMNN0hZUaz2KO9U3XG6nsz7bB7XC8P05hgn + RVBchqJoOvqX2dzbuqLwiXuPYceEQtC3myV+2MTMQ0F/GhtgBB4zRrmqs5wum5zMZ4vgiK0n+Y7a + iB5ZhqWFOJ5ezca7AkzelWQaTaWkwSV3ns1HvAJePj3WC/2zWK4jmKvyZc+CNvgFzNz0U3UCAAA= headers: Alt-Svc: - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 @@ -49,11 +56,11 @@ interactions: Content-Type: - application/json; charset=UTF-8 Date: - - Wed, 25 Mar 2026 14:42:45 GMT + - Thu, 02 Apr 2026 14:59:50 GMT Server: - scaffolding on HTTPServer2 Server-Timing: - - gfet4t7; dur=1793 + - gfet4t7; dur=8863 Transfer-Encoding: - chunked Vary: diff --git a/tests/cassettes/test_nodes/test_planner_pergunta_fora_de_escopo.yaml b/tests/cassettes/test_nodes/test_planner_pergunta_fora_de_escopo.yaml new file mode 100644 index 0000000..5d1b14b --- /dev/null +++ b/tests/cassettes/test_nodes/test_planner_pergunta_fora_de_escopo.yaml @@ -0,0 +1,84 @@ +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: \"Quantas vezes a Ahri ganhou o CBLOL?\"\n- + Schema dispon\u00edvel: Sim\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: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '1554' + Content-Type: + - application/json + Host: + - generativelanguage.googleapis.com + User-Agent: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + x-goog-api-client: + - google-genai-sdk/1.68.0 gl-python/3.10.12 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: !!binary | + H4sIAAAAAAAC/2VSTW/TQBC9+1eM9pxUTQMKgkMFAYlCqxQaVUgYVYM9sTe1d5z9QG2i/Jqe4N5f + kD/G2IkTB3xYzc68efvmeVYRgErQpDpFT069hu+SAVg1Z11j48l4KbQpSVZo/QG7/VadWCCeHuom + tYpNfY9VSol2yLEkY2UoIee0xzuchxRj1WthFdksmLrAd8EFtHrX8ja3Gja/IZQICZYVbZ56QLvw + mRyYzRNDhibHEjxbQ5odpNqSx1ImoDcw5wxTtoLlALQIuqpDB8HrQi+lTeZC+EUmIXsCt5xs/kDG + zosIhJTA4U+ysAgo+pzgltIeypb2QCoQguDkjtDIbigDMIzfXU4uezUSi4xF3Yxsre08VrFZq46F + 6338o3cw3nJBtaslp1S08HULUDNttMu/Ejo2NexmOrlW+6o2KT1I+jRqH2ioVXCY0ZXYJCuA+x+t + Kstl5ad8T2bMoVmB4dnplq2zMkeAV23ds8fiqDQYDAe9/5jde3lXF91l6uyZjImF9o/1LNMP36aq + Y4X/R1hrRtTxTPmcQ5b7Y5Gj4SDaubY18pas01vHMirFw/7Zycv+rECXNy8qWZmKjaOLtMHwaILp + 5/6nq5uPy9HF9fzFeLn4cq+idfQXVWsbQk8DAAA= + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=UTF-8 + Date: + - Thu, 02 Apr 2026 15:43:02 GMT + Server: + - scaffolding on HTTPServer2 + Server-Timing: + - gfet4t7; dur=4508 + 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_componentes.py b/tests/test_componentes.py index b15fc0e..2845b1e 100644 --- a/tests/test_componentes.py +++ b/tests/test_componentes.py @@ -233,3 +233,14 @@ def test_roteador_planejador_aprovado(): estado = {"contexto_schema": "tabelas...", "status": "aprovado"} assert roteador_planejador(estado) == "fim" + +def test_roteador_planejador_espera_humana(): + """Roteador planejador direciona para espera_humana se a flag esperar_usuario for True.""" + from src.routers.edges import roteador_planejador + + estado = { + "espera_humana": True, + "contexto_schema": "tabelas...", + "status": "aguardando_input" + } + assert roteador_planejador(estado) == "espera_humana" \ No newline at end of file diff --git a/tests/test_integracao.py b/tests/test_integracao.py index e79d5a4..f59eb62 100644 --- a/tests/test_integracao.py +++ b/tests/test_integracao.py @@ -12,6 +12,8 @@ import pytest from dotenv import load_dotenv +from src.nodes.schema import _formatar_schema_sqlite + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "olist_relational.db") @@ -60,7 +62,8 @@ def test_grafo_compila(grafo): @pytest.mark.timeout(120) def test_pergunta_simples(grafo): """Pergunta simples percorre o grafo e chega ao status aprovado.""" - resultado = grafo.invoke(_estado_inicial("Quantos pedidos existem no banco?")) + config = {"configurable": {"thread_id": "teste_simples"}} + resultado = grafo.grafo_text_to_insight.invoke(_estado_inicial("Quantos pedidos existem no banco?"), config) assert resultado["status"] == "aprovado" assert resultado["sql_gerada"] != "" @@ -72,8 +75,9 @@ def test_pergunta_simples(grafo): @pytest.mark.timeout(120) def test_pergunta_com_ranking(grafo): """Pergunta com ranking retorna múltiplas linhas.""" - resultado = grafo.invoke( - _estado_inicial("Quais sao as 5 categorias de produtos mais vendidas?") + config = {"configurable": {"thread_id": "teste_simples"}} + resultado = grafo.grafo_text_to_insight.invoke( + _estado_inicial("Quais sao as 5 categorias de produtos mais vendidos por quantidade?"), config ) assert resultado["status"] == "aprovado" @@ -85,12 +89,13 @@ def test_pergunta_com_ranking(grafo): @pytest.mark.timeout(120) def test_estado_final_completo(grafo): """Estado final tem todos os campos-chave preenchidos.""" - resultado = grafo.invoke( - _estado_inicial("Qual o valor medio dos pedidos?") + config = {"configurable": {"thread_id": "teste_estado"}} + resultado = grafo.grafo_text_to_insight.invoke( + _estado_inicial("Qual o valor medio dos pedidos?"), config ) # Campos que devem estar preenchidos ao final assert resultado.get("contexto_schema", "") != "" assert resultado.get("sql_gerada", "") != "" assert resultado.get("saida_terminal", "") != "" - assert resultado.get("tentativas_loop", 0) >= 1 + assert resultado.get("tentativas_loop", 0) >= 1 \ No newline at end of file diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 923216a..43d6c85 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -106,9 +106,33 @@ def test_planner_com_feedback_revisa(llm): } resultado = nos_nodo_planejador(estado, llm) - assert resultado["status"] in ("pronto_codificacao", "revisando_estrategia") + assert resultado["status"] in ("pronto_codificacao", "revisando_estrategia", "aguardando_input") print(f" → Planner decidiu: {resultado['status']}") +@pytest.mark.vcr +@pytest.mark.timeout(60) +def test_planner_pergunta_fora_de_escopo(llm): + """Planner deve detectar pergunta fora de escopo e levantar a flag de HITL.""" + from src.nodes.planner import nos_nodo_planejador + + time.sleep(5) # rate limit + schema = _obter_schema_real() + estado = { + "pergunta_usuario": "Quantas vezes a Ahri ganhou o CBLOL?", + "contexto_schema": schema, + "feedback_critico": "", + "status": "schema_obtido", + "tentativas_loop": 0, + "erro_execucao": "", + } + + resultado = nos_nodo_planejador(estado, llm) + + assert resultado.get("espera_humana") is True + assert resultado.get("status") == "aguardando_input" + assert "pergunta_ao_usuario" in resultado + assert len(resultado["pergunta_ao_usuario"]) > 5 + print(f" → Agente bloqueou com sucesso: {resultado['pergunta_ao_usuario']}") # ============================================================ # CODE AGENT — com API @@ -148,7 +172,7 @@ def test_code_agent_com_feedback_regenera(llm): """Code Agent com feedback do crítico → gera SQL diferente.""" from src.nodes.code_agent.code_agent import nos_nodo_agente_codigo - time.sleep(5) # rate limit + time.sleep(5) schema = _obter_schema_real() estado = { "pergunta_usuario": "Quais as 5 categorias de produtos mais vendidas?", From 32bb2c581c3a232e87823da49324325e86d9afc1 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Fri, 3 Apr 2026 18:50:48 -0300 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20adicionei=20ao=20prompt=20do=20plann?= =?UTF-8?q?er.py=20um=20preview=20de=20500=20caracteres=20do=20schema,=20a?= =?UTF-8?q?gora=20ele=20consegue=20captar=20um=20contexto=20raz=C3=B3avel?= =?UTF-8?q?=20da=20base=20de=20dados=20e=20ajuda=20na=20avalia=C3=A7=C3=A3?= =?UTF-8?q?o=20cr=C3=ADtica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nodes/planner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/nodes/planner.py b/src/nodes/planner.py index 1bf69b9..e7e7be4 100644 --- a/src/nodes/planner.py +++ b/src/nodes/planner.py @@ -21,6 +21,7 @@ - Tentativas realizadas: {tentativas} - Status atual: {status_atual} - Erro anterior: {erro} +- Schema (se disponível): {schema} AVALIAÇÃO CRÍTICA: Verifique se a "Pergunta do usuário" pode ser respondida com as tabelas e colunas do Schema. @@ -75,6 +76,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", ) resposta_llm = llm.invoke(prompt) From b379919b291a2b3f118d78d6851bc6daecfb58fe Mon Sep 17 00:00:00 2001 From: Fuganti Date: Fri, 10 Apr 2026 16:54:42 -0300 Subject: [PATCH 3/9] refactor: Atualiza modelo HITL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mudei a lógica do HITL para, ao invés de concatenar respostas, prover um hsitórico de conversa tanto para o planner quanto para o code_agent --- main.py | 15 ++++++--------- src/nodes/code_agent/code_agent.py | 6 ++++++ src/nodes/planner.py | 9 +++++++-- src/state.py | 1 + tests/test_integracao.py | 1 + 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index 71b5b80..6119f2e 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,7 @@ def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: "saida_terminal": "", "feedback_critico": "", "erro_execucao": "", + "historico_conversa": [], "status": "iniciado", "tentativas_loop": 0, "db_path": "data/olist_relational.db", @@ -55,18 +56,14 @@ def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: if "espera_humana" in snapshot.next: pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") - pergunta_original = snapshot.values.get("pergunta_usuario", "") + historico_atual = snapshot.values.get("historico_conversa", []) + print(f"\n[HITL]: {pergunta_agente}") - resposta = input("Resposta: ") + resposta = input("[RESPOSTA USUARIO]: ") + historico_atual.append(("ai: "+pergunta_agente, "\nuser: "+resposta)) - grafo.grafo_text_to_insight.update_state( - config, - { - "pergunta_usuario": f"{pergunta_original}.\n[Contexto Adicional do Usuário]: {resposta}", - "espera_humana": False, - }, - ) + grafo.grafo_text_to_insight.update_state(config, {"historico_conversa": historico_atual, "espera_humana": False}) estado_inicial = None diff --git a/src/nodes/code_agent/code_agent.py b/src/nodes/code_agent/code_agent.py index 94c9f76..e0557ba 100644 --- a/src/nodes/code_agent/code_agent.py +++ b/src/nodes/code_agent/code_agent.py @@ -29,6 +29,10 @@ === PERGUNTA DO USUÁRIO === {pergunta} +=== CONVERSA PRÉVIA (CONTEXTO ADICIONAL) === +{conversa_previa} + +=== FEEDBACK CRÍTICO (SE HOUVER) === {feedback_section} Responda APENAS com a consulta SQL, sem markdown, sem explicação.""" @@ -49,6 +53,7 @@ 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", "") + conversa_previa = estado.get("historico_conversa", "") schema = estado.get("contexto_schema", "") feedback = estado.get("feedback_critico", "") tentativas = estado.get("tentativas_loop", 0) @@ -66,6 +71,7 @@ def nos_nodo_agente_codigo(estado: EstadoTextToInsight, llm: ChatGoogleGenerativ prompt = PROMPT_TEMPLATE.format( schema=schema, pergunta=pergunta, + conversa_previa=conversa_previa if conversa_previa else "Nenhuma", feedback_section=feedback_section, ) diff --git a/src/nodes/planner.py b/src/nodes/planner.py index 5367568..93ab1b4 100644 --- a/src/nodes/planner.py +++ b/src/nodes/planner.py @@ -18,12 +18,15 @@ Contexto atual: - Pergunta do usuário: "{pergunta}" -- Schema disponível: {schema_disponivel} + +- conversa_previa: {conversa_previa} + +- Schema: {schema} + - Feedback do crítico: {feedback} - Tentativas realizadas: {tentativas} - Status atual: {status_atual} - Erro anterior: {erro} -- Schema (se disponível): {schema} AVALIAÇÃO CRÍTICA: Verifique se a "Pergunta do usuário" pode ser respondida com as tabelas e colunas do Schema. @@ -49,6 +52,7 @@ 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", "") + conversa_previa = estado.get("historico_conversa", "") schema = estado.get("contexto_schema", "") feedback = estado.get("feedback_critico", "") tentativas = estado.get("tentativas_loop", 0) @@ -96,6 +100,7 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI 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", + conversa_previa=conversa_previa if conversa_previa else "Nenhuma", ) resposta_llm = llm.invoke(prompt) diff --git a/src/state.py b/src/state.py index 0b8e1cc..9d32550 100644 --- a/src/state.py +++ b/src/state.py @@ -61,6 +61,7 @@ class EstadoTextToInsight(EstadoEntrada, total = False): status: StatusExecucao espera_humana: bool pergunta_ao_usuario: str + historico_conversa: list[tuple[str, str]] tentativas_loop: int resposta_natural: str diff --git a/tests/test_integracao.py b/tests/test_integracao.py index f59eb62..d575470 100644 --- a/tests/test_integracao.py +++ b/tests/test_integracao.py @@ -42,6 +42,7 @@ def rate_limit_delay(): def _estado_inicial(pergunta: str) -> dict: return { "pergunta_usuario": pergunta, + "historico_conversa": [], "contexto_schema": "", "sql_gerada": "", "saida_terminal": "", From 974118de1155bdbee6274f54b48d1191a2bdd0af Mon Sep 17 00:00:00 2001 From: Fuganti Date: Fri, 10 Apr 2026 17:47:27 -0300 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20Adiciona=20sele=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20modelo=20e=20contexto=20pro=20critic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agora que possuimos histórico de conversa, dei acesso dele ao critic e possibilitei a seleção de openai ou google na hr de usar o modelo de API --- main.py | 5 +++-- requirements.txt | 1 + src/graph.py | 13 ++++--------- src/model_selection.py | 10 ++++++++++ src/nodes/critic.py | 6 +++++- tests/test_integracao.py | 2 +- 6 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 src/model_selection.py diff --git a/main.py b/main.py index 6119f2e..d20f265 100644 --- a/main.py +++ b/main.py @@ -129,9 +129,10 @@ def main(): pergunta = "Quantos pedidos existem no banco?" print(f"Nenhuma pergunta fornecida. Usando exemplo: '{pergunta}'\n") - api_key = os.getenv("GOOGLE_API_KEY") + api_key = os.getenv("GOOGLE_API_KEY") #GOOGLE_API_KEY/OPENAI_API_KEY + model = "gemini-2.5-flash" #gemini-2.5-flash/gpt-5-nano - grafo = Graph(api_key) + grafo = Graph(api_key, model) resultado = executar_consulta(grafo, pergunta) exibir_resultado(resultado) diff --git a/requirements.txt b/requirements.txt index aabaf4e..30663a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ langchain>=0.2.0 langchain-core>=0.2.0 python-dotenv>=1.0.0 langchain-google-genai>=2.0.0 +langchain-openai pytest>=9.0.2 pytest-recording>=0.13.0 pytest-timeout>=2.3.0 \ No newline at end of file diff --git a/src/graph.py b/src/graph.py index 11769cb..8e4793e 100644 --- a/src/graph.py +++ b/src/graph.py @@ -13,7 +13,6 @@ from functools import partial from langgraph.graph import StateGraph, START, END -from langchain_google_genai import ChatGoogleGenerativeAI from langgraph.checkpoint.memory import MemorySaver from .state import EstadoTextToInsight @@ -26,20 +25,16 @@ nos_nodo_resposta, ) from .routers import roteador_sandbox, roteador_planejador +from .model_selection import get_model def nos_nodo_espera_humana(estado: EstadoTextToInsight): """Nó estrutural: serve apenas como breakpoint para o HITL.""" return estado class Graph: - def __init__(self, api_key): #Essa definição do grafo pode mudar pro caso de utilizarmos diferentes modelos - - self.llm = ChatGoogleGenerativeAI( - model="gemini-2.5-flash", - # model="gemini-3-flash", #Aqui coloquei manualmente o gemini 2.5, mas podemos deixar na definição do grafo - #um campo pro usuário utilizar mais modelos no futuro - google_api_key=api_key - ) + def __init__(self, api_key, model): + + self.llm = get_model(model, api_key) self.memory = MemorySaver() self.grafo_text_to_insight = self._compilar_grafo() diff --git a/src/model_selection.py b/src/model_selection.py new file mode 100644 index 0000000..6bc65f5 --- /dev/null +++ b/src/model_selection.py @@ -0,0 +1,10 @@ +from langchain_google_genai import ChatGoogleGenerativeAI +from langchain_openai import ChatOpenAI + +def get_model(model_name: str, api_key: str): + if "gemini" in model_name.lower(): + return ChatGoogleGenerativeAI(model=model_name, google_api_key=api_key) + elif "gpt" in model_name.lower(): + return ChatOpenAI(model=model_name, openai_api_key=api_key) + else: + raise ValueError(f"Modelo '{model_name}' não reconhecido. Use 'gemini' ou 'gpt' no nome do modelo.") diff --git a/src/nodes/critic.py b/src/nodes/critic.py index e8e1d45..04e53c7 100644 --- a/src/nodes/critic.py +++ b/src/nodes/critic.py @@ -18,6 +18,9 @@ === PERGUNTA DO USUÁRIO === {pergunta} +=== CONVERSA COM O AGENTE (se houver) === +{conversa_previa} + === SQL GERADA === {sql} @@ -49,6 +52,7 @@ def nos_nodo_critico(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) - preview = estado.get("linhas_resultado_preview", []) total = estado.get("total_linhas_resultado", 0) saida = estado.get("saida_terminal", "") + conversa_previa = estado.get("historico_conversa", "") erro = estado.get("erro_execucao", "") status_exec = estado.get("status", "") @@ -70,6 +74,7 @@ def nos_nodo_critico(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) - pergunta=pergunta, sql=sql, status_exec=status_exec, + conversa_previa=conversa_previa if conversa_previa else "Nenhuma", total_linhas=total, preview=preview_str, erro=erro if erro else "Nenhum", @@ -100,7 +105,6 @@ def nos_nodo_critico(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) - return { "feedback_critico": feedback, "status": veredito, - "tokens_input": in_tokens, "tokens_output": out_tokens, "tokens_total": total_tokens, diff --git a/tests/test_integracao.py b/tests/test_integracao.py index d575470..940e30e 100644 --- a/tests/test_integracao.py +++ b/tests/test_integracao.py @@ -29,7 +29,7 @@ def grafo(): pytest.skip("Variável GOOGLE_API_KEY não encontrada. Pulando testes de integração.") from src.graph import Graph - return Graph(api_key) + return Graph(api_key, "gemini-2.5-flash") @pytest.fixture(autouse=True) From cb32e8c40d5db52cf9059b10150f4a7d356e2391 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Sun, 19 Apr 2026 21:55:21 -0300 Subject: [PATCH 5/9] feat(cli): adiciona toggle de HITL com fallback de bloqueio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O sistema agora suporta a desativação do HITL via linha de comando - Introduz flag `--hitl {on,off}` via `argparse` (default: on). - Altera `executar_consulta` para respeitar o estado de `hitl_ativado`. - Registra `erro_execucao` e salva as métricas em CSV mesmo no encerramento por bloqueio. - Exibe o estado atual do HITL no início da execução da main. - Atualiza `DESENVOLVIMENTO.md` com tutorial e cenários de uso da flag. - Adiciona `.gitattributes` para forçar padronização de quebra de linha (LF). --- .gitattributes | 1 + DESENVOLVIMENTO.md | 40 ++++++++++++++++++++++++++++++++++ main.py | 53 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/DESENVOLVIMENTO.md b/DESENVOLVIMENTO.md index 797653f..807b1f1 100644 --- a/DESENVOLVIMENTO.md +++ b/DESENVOLVIMENTO.md @@ -49,6 +49,46 @@ python main.py "Quantos pedidos existem no banco?" python main.py ``` +### Tutorial da flag `--hitl` + +A CLI agora aceita o toggle `--hitl {on,off}` para controlar o modo Human-in-the-Loop. + +#### 1) Comportamento padrão (sem informar flag) + +Se você não passar `--hitl`, o modo fica **ativado automaticamente** (`on`). + +```bash +python main.py "Quantos pedidos existem no banco?" +``` + +#### 2) Forçar HITL ligado + +Use quando quiser interação humana no terminal caso o planejador peça esclarecimentos. + +```bash +python main.py --hitl on "Quais foram os principais fatores de queda no lucro?" +``` + +Quando o fluxo precisar de ajuda humana, o terminal pergunta e aguarda input: + +```text +[HITL]: +[RESPOSTA USUARIO]: +``` + +#### 3) Desligar HITL + +Use para execução não interativa (scripts, pipelines, CI, etc.). + +```bash +python main.py --hitl off "Quais foram os principais fatores de queda no lucro?" +``` + +Se o grafo chegar em `espera_humana` com `--hitl off`: +- o sistema **não** chama `input()`; +- encerra a execução com status `bloqueado_hitl`; +- registra erro explicando que havia necessidade de intervenção humana com HITL desativado. + ## Testes 3 camadas, do mais rápido ao mais completo: diff --git a/main.py b/main.py index d20f265..8dc71c2 100644 --- a/main.py +++ b/main.py @@ -3,8 +3,8 @@ Script principal para demonstração do grafo Text-to-Insight. """ +import argparse import time -import sys import os from dotenv import load_dotenv from langgraph.graph import StateGraph @@ -13,7 +13,7 @@ load_dotenv() -def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: +def executar_consulta(grafo: StateGraph, pergunta: str, hitl_ativado: bool = True) -> dict: """Executa uma consulta através do grafo Text-to-Insight.""" config = {"configurable": {"thread_id": "sessao_usuario_1"}} estado_inicial = { @@ -58,6 +58,25 @@ def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") historico_atual = snapshot.values.get("historico_conversa", []) + if not hitl_ativado: + print("\n[HITL] Intervenção humana solicitada, mas o modo HITL está DESATIVADO.") + print("[HITL] Encerrando execução com status de bloqueio.") + + resultado_final = dict(snapshot.values) + resultado_final.update({ + "status": "bloqueado_hitl", + "erro_execucao": ( + "Fluxo bloqueado: o planejador solicitou intervenção humana, " + "mas o HITL está desativado (--hitl off)." + ), + "saida_terminal": "[HITL] Bloqueado: intervenção humana necessária com HITL off.", + }) + + lat_fim = time.perf_counter() + latencia_consulta = lat_fim - lat_inicio + salvar_metricas_csv(resultado_final, latencia_consulta) + return resultado_final + print(f"\n[HITL]: {pergunta_agente}") resposta = input("[RESPOSTA USUARIO]: ") @@ -72,6 +91,25 @@ def executar_consulta(grafo: StateGraph, pergunta: str) -> dict: # return resultado_final +def _parse_args() -> argparse.Namespace: + """Faz parse dos argumentos da CLI.""" + parser = argparse.ArgumentParser( + description="Executa o pipeline Text-to-Insight para responder perguntas sobre o banco SQLite." + ) + parser.add_argument( + "--hitl", + choices=["on", "off"], + default="on", + help="Ativa/desativa o modo Human-in-the-Loop. Padrão: on.", + ) + parser.add_argument( + "pergunta", + nargs="*", + help="Pergunta em linguagem natural. Se omitida, usa uma pergunta padrão.", + ) + return parser.parse_args() + + def exibir_resultado(resultado: dict) -> None: """Exibe o resultado final da execução de forma formatada.""" print("\n" + "=" * 70) @@ -123,18 +161,23 @@ def exibir_resultado(resultado: dict) -> None: def main(): - if len(sys.argv) > 1: - pergunta = " ".join(sys.argv[1:]) + args = _parse_args() + + if args.pergunta: + pergunta = " ".join(args.pergunta) else: pergunta = "Quantos pedidos existem no banco?" print(f"Nenhuma pergunta fornecida. Usando exemplo: '{pergunta}'\n") + hitl_ativado = args.hitl == "on" + print(f"[CONFIG] HITL: {'ATIVADO' if hitl_ativado else 'DESATIVADO'}") + api_key = os.getenv("GOOGLE_API_KEY") #GOOGLE_API_KEY/OPENAI_API_KEY model = "gemini-2.5-flash" #gemini-2.5-flash/gpt-5-nano grafo = Graph(api_key, model) - resultado = executar_consulta(grafo, pergunta) + resultado = executar_consulta(grafo, pergunta, hitl_ativado=hitl_ativado) exibir_resultado(resultado) From 8dfa9381ddd2e38ae977661246f5c7e547e1a121 Mon Sep 17 00:00:00 2001 From: Fuganti Date: Mon, 20 Apr 2026 18:50:59 -0300 Subject: [PATCH 6/9] refactor: toggle + lib class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adicionei a classe da biblioteca ainda sem mudar a lógica na main e sem atualizar os testes para ela; Além disso, mudei a lógica do toggle para remover completamente essa opção do planner quando o hitl não esta disponível --- main.py | 2 +- src/InsightEngine.py | 179 +++++++++++++++++++++++++++++++++++++++++++ src/graph.py | 12 +-- src/nodes/planner.py | 42 ++++++---- 4 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 src/InsightEngine.py diff --git a/main.py b/main.py index 8dc71c2..1e9e899 100644 --- a/main.py +++ b/main.py @@ -175,7 +175,7 @@ def main(): api_key = os.getenv("GOOGLE_API_KEY") #GOOGLE_API_KEY/OPENAI_API_KEY model = "gemini-2.5-flash" #gemini-2.5-flash/gpt-5-nano - grafo = Graph(api_key, model) + grafo = Graph(api_key, model, hitl_ativado) resultado = executar_consulta(grafo, pergunta, hitl_ativado=hitl_ativado) exibir_resultado(resultado) diff --git a/src/InsightEngine.py b/src/InsightEngine.py new file mode 100644 index 0000000..0caa88a --- /dev/null +++ b/src/InsightEngine.py @@ -0,0 +1,179 @@ +import time +import os +from dotenv import load_dotenv +from langgraph.graph import StateGraph +from src.graph import Graph +from src.utils import salvar_metricas_csv + +class InsightEngine: + def __init__(self, api_key, model, db_path, hitl=False, show_output=False): + self._hitl_ativado = hitl + self._show_output = show_output + print(f"[CONFIG] HITL: {'ATIVADO' if self._hitl_ativado else 'DESATIVADO'}") + print(f"[CONFIG] SHOW_OUTPUT: {'ATIVADO' if self._show_output else 'DESATIVADO'}") + + self._model = model #gemini-2.5-flash/gpt-5-nano + self._db_path = db_path #"data/olist_relational.db" + + self._grafo = Graph(api_key, self._model, self._hitl_ativado) + + def _exibir_resultado(self, resultado: dict) -> None: + """Exibe o resultado final da execução de forma formatada.""" + print("\n" + "=" * 70) + print("EXECUCAO CONCLUIDA") + print("=" * 70) + + print(f"\nStatus Final: {resultado.get('status', 'desconhecido').upper()}") + print(f"Total de Tentativas: {resultado.get('tentativas_loop', 0)}") + + print("\n" + "-" * 70) + print("SQL GERADA:") + print("-" * 70) + sql = resultado.get("sql_gerada", "").strip() + print(sql if sql else "[Nenhuma SQL gerada]") + + print("\n" + "-" * 70) + print("SAIDA DA EXECUCAO:") + print("-" * 70) + saida = resultado.get("saida_terminal", "").strip() + print(saida if saida else "[Nenhuma saida]") + + print("\n" + "-" * 70) + print("RESULTADO (preview):") + print("-" * 70) + preview = resultado.get("linhas_resultado_preview", []) + total = resultado.get("total_linhas_resultado", 0) + if preview: + for row in preview[:10]: + print(row) + if total > 10: + print(f"... ({total - 10} linhas omitidas)") + else: + print("[Nenhum resultado]") + + print("\n" + "-" * 70) + print("FEEDBACK DO CRITICO:") + print("-" * 70) + feedback = resultado.get("feedback_critico", "").strip() + print(feedback if feedback else "[Nenhum feedback]") + + # Se o nó de resposta final gerou uma resposta em linguagem natural, exibi-la + resposta_natural = resultado.get("resposta_natural", "").strip() + print("\n" + "-" * 70) + print("RESPOSTA NATURAL AO USUARIO:") + print("-" * 70) + print(resposta_natural) + + print("\n" + "=" * 70 + "\n") + + def get_insight(self, thread_id, query=None, user_response=None): + """Executa uma consulta através do grafo Text-to-Insight.""" + config = {"configurable": {"thread_id": thread_id}} + + snapshot = self._grafo.grafo_text_to_insight.get_state(config) + + if snapshot.next and user_response: + historico = snapshot.values.get("historico_conversa", []) + pergunta_ai = snapshot.values.get("pergunta_ao_usuario", "") + historico.append((f"ai: {pergunta_ai}", f"user: {user_response}")) + + self._grafo.grafo_text_to_insight.update_state( + config, {"historico_conversa": historico, "espera_humana": False} + ) + estado_execucao = None + else: + estado_execucao = { + "pergunta_usuario": query, + "contexto_schema": "", + "sql_gerada": "", + "saida_terminal": "", + "feedback_critico": "", + "erro_execucao": "", + "historico_conversa": [], + "status": "iniciado", + "tentativas_loop": 0, + "db_path": self._db_path, + "espera_humana": False, + } + + pergunta_exibicao = query if query else snapshot.values.get("pergunta_usuario", "Retomando conversa...") + + print("=" * 70) + print("INICIANDO TEXT-TO-INSIGHT") + print("=" * 70) + print(f"\nPergunta: {pergunta_exibicao}\n") + print("=" * 70) + + # Intervalo de cálculo da latência da consulta no grafo. Optei por considerar somente + # o tempo em que o grafo de fato está rodando, então não incluo o tempo que leva as linhas + # anteriores na main ou antes desse trecho. + lat_inicio = time.perf_counter() + + while True: + for evento in self._grafo.grafo_text_to_insight.stream(estado_execucao, config, stream_mode="values"): + pass + + snapshot = self._grafo.grafo_text_to_insight.get_state(config) + + if not snapshot.next: + resultado_final = snapshot.values + lat_fim = time.perf_counter() + latencia_consulta = lat_fim - lat_inicio + salvar_metricas_csv(resultado_final, latencia_consulta) + if self._show_output: + self._exibir_resultado(resultado_final) + return resultado_final + + if "espera_humana" in snapshot.next: + pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") + historico_atual = snapshot.values.get("historico_conversa", []) + + #discutir com na próxima reunião qual a necessidade de manter esse bloco/mudar a estratégia de toggle de remover completamente ṕara bloquear o fluxo + if not self._hitl_ativado: + print("\n[HITL] Intervenção humana solicitada, mas o modo HITL está DESATIVADO.") + print("[HITL] Encerrando execução com status de bloqueio.") + + resultado_final = dict(snapshot.values) + resultado_final.update({ + "status": "bloqueado_hitl", + "erro_execucao": ( + "Fluxo bloqueado: o planejador solicitou intervenção humana, " + "mas o HITL está desativado (--hitl off)." + ), + "saida_terminal": "[HITL] Bloqueado: intervenção humana necessária com HITL off.", + }) + + lat_fim = time.perf_counter() + latencia_consulta = lat_fim - lat_inicio + salvar_metricas_csv(resultado_final, latencia_consulta) + return resultado_final + + return { + "status": "AWAITING_USER", + "message": pergunta_agente, + "chat_history": historico_atual, + "thread_id": thread_id + } + + #Essa implementação anterior era mais simples, lembrar de avaliar complexidade de uso da lib + # print(f"\n[HITL]: {pergunta_agente}") + + # resposta = input("[RESPOSTA USUARIO]: ") + # historico_atual.append(("ai: "+pergunta_agente, "\nuser: "+resposta)) + + # self._grafo.grafo_text_to_insight.update_state(config, {"historico_conversa": historico_atual, "espera_humana": False}) + + # estado_inicial = None + +if __name__ == "__main__": + load_dotenv() + API_KEY = os.getenv("OPENAI_API_KEY") #GOOGLE_API_KEY/OPENAI_API_KEY + MODEL = "gpt-5-nano" #gemini-2.5-flash/gpt-5-nano + DB_PATH = "data/olist_relational.db" + engine = InsightEngine(API_KEY, MODEL, DB_PATH, hitl=True, show_output=True) + resultado = engine.get_insight("thread_1", "Liste os itens mais quentes do mercado") #Quantos pedidos existem no banco? + if resultado.get("status") == "AWAITING_USER": + print("\n[HITL] Aguardando resposta do usuário...") + print(f"Pergunta do agente: {resultado.get('message')}") + print(f"Histórico de conversa até agora: {resultado.get('chat_history')}") + engine.get_insight("thread_1", user_response="Sim, pode prosseguir, faça todas as assunções necessárias.") \ No newline at end of file diff --git a/src/graph.py b/src/graph.py index 8e4793e..7a6065a 100644 --- a/src/graph.py +++ b/src/graph.py @@ -32,20 +32,20 @@ def nos_nodo_espera_humana(estado: EstadoTextToInsight): return estado class Graph: - def __init__(self, api_key, model): + def __init__(self, api_key, model, hitl): self.llm = get_model(model, api_key) self.memory = MemorySaver() - self.grafo_text_to_insight = self._compilar_grafo() + self.grafo_text_to_insight = self._compilar_grafo(hitl) - def _construir_grafo_text_to_insight(self) -> StateGraph: + def _construir_grafo_text_to_insight(self, hitl) -> StateGraph: """ Constrói e compila o grafo de agentes Text-to-Insight. """ construtor_grafo = StateGraph(EstadoTextToInsight) # 1. ADICIONAR NÓS - construtor_grafo.add_node("planejador", partial(nos_nodo_planejador, llm=self.llm)) + construtor_grafo.add_node("planejador", partial(nos_nodo_planejador, llm=self.llm, hitl=hitl)) construtor_grafo.add_node("espera_humana", nos_nodo_espera_humana) construtor_grafo.add_node("esquema", nos_nodo_esquema) construtor_grafo.add_node("agente_codigo", partial(nos_nodo_agente_codigo, llm=self.llm)) @@ -103,8 +103,8 @@ def roteador_critico(estado: EstadoTextToInsight) -> str: return construtor_grafo - def _compilar_grafo(self) -> "CompiledStateGraph": - construtor = self._construir_grafo_text_to_insight() + def _compilar_grafo(self, hitl) -> "CompiledStateGraph": + construtor = self._construir_grafo_text_to_insight(hitl) grafo_compilado = construtor.compile(checkpointer=self.memory, interrupt_before=["espera_humana"]) print("[GRAFO] Grafo Text-to-Insight compilado com sucesso!") diff --git a/src/nodes/planner.py b/src/nodes/planner.py index 93ab1b4..ed7a33b 100644 --- a/src/nodes/planner.py +++ b/src/nodes/planner.py @@ -28,24 +28,11 @@ - Status atual: {status_atual} - Erro anterior: {erro} -AVALIAÇÃO CRÍTICA: -Verifique se a "Pergunta do usuário" pode ser respondida com as tabelas e colunas do Schema. -Se houver ambiguidade, conceitos não mapeados no banco de dados, ou se a intenção do usuário não estiver clara, você DEVE pedir mais informações. - -Responda EXATAMENTE no formato JSON abaixo, sem formatação markdown (```json): -{{ - "decisao": "escolha_uma_opcao", - "pergunta_ao_usuario": "escreva a pergunta aqui se precisar de ajuda, ou deixe vazio se não precisar" -}} - -Opções válidas para 'decisão': -- "pronto_codificacao" → se temos schema, a pergunta faz sentido e devemos gerar/regenerar SQL -- "revisando_estrategia" → se o crítico reprovou e devemos tentar uma abordagem diferente -- "necessita_ajuda" → a pergunta não é clara, não faz sentido, falta contexto ou não há dados no schema para responder. +{diretrizes} """ -def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI) -> dict: +def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI, hitl: bool) -> dict: """ Nó Planejador: decide a próxima etapa do fluxo. @@ -89,6 +76,30 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI "tokens_output": out_tokens, "tokens_total": total_tokens, } + + diretrizes = """Responda EXATAMENTE no formato JSON abaixo, sem formatação markdown (```json): +{{ + "decisao": "escolha_uma_opcao" +}} +Opções válidas para 'decisão': +- "pronto_codificacao" → se temos schema, a pergunta faz sentido e devemos gerar/regenerar SQL +- "revisando_estrategia" → se o crítico reprovou e devemos tentar uma abordagem diferente""" + + if hitl: + diretrizes = """AVALIAÇÃO CRÍTICA: +Verifique se a "Pergunta do usuário" pode ser respondida com as tabelas e colunas do Schema. +Se houver ambiguidade, conceitos não mapeados no banco de dados, ou se a intenção do usuário não estiver clara, você DEVE pedir mais informações. + +Responda EXATAMENTE no formato JSON abaixo, sem formatação markdown (```json): +{{ + "decisao": "escolha_uma_opcao", + "pergunta_ao_usuario": "escreva a pergunta aqui se precisar de ajuda, ou deixe vazio se não precisar" +}} + +Opções válidas para 'decisão': +- "pronto_codificacao" → se temos schema, a pergunta faz sentido e devemos gerar/regenerar SQL +- "revisando_estrategia" → se o crítico reprovou e devemos tentar uma abordagem diferente +- "necessita_ajuda" → a pergunta não é clara, não faz sentido, falta contexto ou não há dados no schema para responder.""" # Usa LLM para decidir estratégia prompt = PROMPT_PLANNER.format( @@ -101,6 +112,7 @@ def nos_nodo_planejador(estado: EstadoTextToInsight, llm: ChatGoogleGenerativeAI # apenas primeiros 500 caracteres do schema para evitar estourar o prompt, mas pode ser ajustado conforme necessidade schema=schema[:500] if schema else "Nenhum", conversa_previa=conversa_previa if conversa_previa else "Nenhuma", + diretrizes=diretrizes, ) resposta_llm = llm.invoke(prompt) From 882ca688d5c95618e68f84267afdbea32a888458 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Tue, 21 Apr 2026 16:48:01 -0300 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20atualiza=20documenta=C3=A7=C3=A3o?= =?UTF-8?q?=20para=20refletir=20fluxo=20real=20com=20HITL=20e=20resposta?= =?UTF-8?q?=20natural?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Alinha README e ARQUITETURA com o fluxo atual do grafo (7 nós, roteamento revisado e etapa de resposta natural após aprovação do crítico). - Documenta o caminho de Human-in-the-Loop (espera_humana), a interrupção controlada e o comportamento quando o modo HITL está desligado. - Atualiza a semântica do estado compartilhado com campos de conversa, resposta final e telemetria de tokens. - Registra os status operacionais usados em runtime (aguardando_input e bloqueado_hitl). - Corrige o guia de desenvolvimento com validação de import da classe Graph e com a estrutura real de módulos/pastas. - Atualiza o índice técnico do projeto (INDICE.py) com a arquitetura vigente, componentes atuais e métricas/observabilidade. - Sem alteração de lógica de código, apenas documentação --- ARQUITETURA.md | 70 +++++++++++++++++++++++++++++++++++++--------- DESENVOLVIMENTO.md | 10 +++++-- INDICE.py | 41 ++++++++++++++++----------- README.md | 29 +++++++++++++++---- 4 files changed, 113 insertions(+), 37 deletions(-) diff --git a/ARQUITETURA.md b/ARQUITETURA.md index 08a50ce..ba88c58 100644 --- a/ARQUITETURA.md +++ b/ARQUITETURA.md @@ -2,7 +2,7 @@ ## Visão geral -O sistema usa um grafo de agentes (LangGraph) com 5 nós e 3 roteadores condicionais. Três nós usam LLM (Gemini), um usa introspecção SQLite e um executa SQL diretamente. +O sistema usa um grafo de agentes (LangGraph) com 7 nós e 3 roteadores condicionais. Quatro nós podem usar LLM (Planejador, Agente de Código, Crítico e Resposta), um usa introspecção SQLite, um executa SQL diretamente e um funciona como breakpoint estrutural para HITL. ``` START @@ -12,6 +12,13 @@ START |-- schema vazio? --> [Schema] --> extrai metadados do banco | | | v + |--------------------- volta ao Planejador + | + |-- precisa ajuda? --> [Espera Humana] --> interrompe fluxo (HITL) + | | + | v + |--------------------- volta ao Planejador + | |-- pronto? --------> [Agente Código] --> gera SQL (LLM) | | | v @@ -20,7 +27,7 @@ START | v | [Crítico] --> avalia resultado (LLM) | | - | aprovado? -- Sim --> END + | aprovado? -- Sim --> [Resposta] --> END | -- Não --> volta ao Planejador | |-- aprovado? ------> END @@ -33,11 +40,22 @@ START Decide a próxima etapa do fluxo. - **Sem schema**: retorna `"aguardando_schema"` (determinístico, sem API) -- **Com schema**: chama Gemini para decidir entre `"pronto_codificacao"` ou `"revisando_estrategia"` +- **Com schema**: chama Gemini para decidir entre `"pronto_codificacao"`, `"revisando_estrategia"` ou `"necessita_ajuda"` (quando HITL está ativo) - **Já aprovado**: mantém `"aprovado"` -Entrada: `pergunta_usuario`, `contexto_schema`, `feedback_critico`, `status` -Saída: `status` +Entrada: `pergunta_usuario`, `historico_conversa`, `contexto_schema`, `feedback_critico`, `status`, `erro_execucao`, `tentativas_loop` +Saída: `status`, `espera_humana`, `pergunta_ao_usuario`, `tentativas_loop`, métricas de token + +### Espera Humana (`src/graph.py`) + +Nó estrutural para Human-in-the-Loop (HITL). + +- Não transforma dados +- Existe para permitir interrupção controlada via `interrupt_before=["espera_humana"]` +- Após coleta de input no terminal, o fluxo retorna ao Planejador + +Entrada: estado atual completo +Saída: estado inalterado ### Schema (`src/nodes/schema.py`) @@ -59,7 +77,7 @@ Gera SQL a partir da pergunta + schema usando Gemini. - Incrementa `tentativas_loop` Entrada: `pergunta_usuario`, `contexto_schema`, `feedback_critico` -Saída: `sql_gerada`, `status`, `tentativas_loop` +Saída: `sql_gerada`, `status`, `tentativas_loop`, métricas de token Utilidades auxiliares em `code_sql.py`: - `validar_sql_segura()`: bloqueia INSERT, UPDATE, DELETE, DROP, etc. Permite apenas SELECT e WITH. @@ -85,7 +103,18 @@ Avalia se o resultado responde à pergunta original usando Gemini. - Retorna veredito (`"aprovado"` / `"reprovado"`) e feedback textual Entrada: `pergunta_usuario`, `sql_gerada`, `linhas_resultado_preview`, `status` -Saída: `feedback_critico`, `status` +Saída: `feedback_critico`, `status`, métricas de token + +### Resposta (`src/nodes/response.py`) + +Gera resposta final em linguagem natural quando o resultado foi aprovado. + +- Só executa geração textual quando `status == "aprovado"` +- Em `pytest`/`CI`, usa fallback determinístico sem API +- Em execução normal, usa LLM para sumarizar pergunta + resultado + +Entrada: `pergunta_usuario`, `sql_gerada`, `linhas_resultado_preview`, `total_linhas_resultado`, `saida_terminal`, `status` +Saída: `resposta_natural`, `status`, métricas de token ## Roteadores (`src/routers/edges.py`) @@ -95,12 +124,14 @@ Saída: `feedback_critico`, `status` - Caso contrário → Planejador ### Após Planejador (`roteador_planejador`) +- `espera_humana=True` → Espera Humana - Schema vazio → Schema - `pronto_codificacao` ou `revisando_estrategia` → Agente Código - `aprovado` → END +- Default → Planejador ### Após Crítico (`roteador_critico`, definido em `graph.py`) -- `aprovado` → END +- `aprovado` → Resposta - Caso contrário → Planejador ## Estado compartilhado (`src/state.py`) @@ -118,20 +149,33 @@ total_linhas_resultado: int # Total de linhas retornadas erro_execucao: str # Mensagem de erro (se houver) saida_terminal: str # Saída resumida da execução feedback_critico: str # Feedback do crítico +espera_humana: bool # Flag para interrupção HITL +pergunta_ao_usuario: str # Pergunta que será exibida no terminal +historico_conversa: list[tuple[str, str]] # Histórico humano/agente +resposta_natural: str # Resposta final em linguagem natural status: StatusExecucao # Estágio atual do fluxo tentativas_loop: int # Contador de tentativas + +# Telemetria +tokens_input: int +tokens_output: int +tokens_total: int ``` Status possíveis: `iniciado`, `aguardando_schema`, `schema_obtido`, `pronto_codificacao`, `sql_gerada`, `exec_ok`, `exec_erro`, `revisando_estrategia`, `aprovado`, `reprovado`. +Status operacionais adicionais em runtime: `aguardando_input` (pedido de HITL) e `bloqueado_hitl` (quando a CLI roda com `--hitl off` e há necessidade de intervenção humana). + ## Fluxo típico ``` 1. START → Planejador (schema vazio → "aguardando_schema") 2. → Schema (extrai metadados do SQLite) -3. → Agente Código (gera SQL com Gemini) -4. → Executor (executa SQL no banco) -5. → Crítico (avalia resultado com Gemini) -6. → Se aprovado: END - Se reprovado: volta ao passo 1 com feedback +3. → Planejador reavalia estratégia +4. → (Opcional) Espera Humana se houver ambiguidade/falta de contexto +5. → Agente Código (gera SQL com Gemini) +6. → Executor (executa SQL no banco) +7. → Crítico (avalia resultado com Gemini) +8. → Se aprovado: Resposta → END + Se reprovado: volta ao Planejador com feedback ``` diff --git a/DESENVOLVIMENTO.md b/DESENVOLVIMENTO.md index 807b1f1..f205582 100644 --- a/DESENVOLVIMENTO.md +++ b/DESENVOLVIMENTO.md @@ -36,7 +36,7 @@ Colocar o banco SQLite em `data/` (ex: `data/olist_relational.db`). ### Validar instalação ```bash -python -c "from src.graph import grafo_text_to_insight; print('OK')" +python -c "from src.graph import Graph; print('OK')" ``` ## Executando @@ -127,6 +127,8 @@ TextToInsight/ ├── src/ │ ├── state.py # EstadoTextToInsight (TypedDict) │ ├── graph.py # Grafo LangGraph +│ ├── model_selection.py # Seleção de modelo/provedor LLM +│ ├── utils.py # Tokens e métricas CSV │ ├── nodes/ │ │ ├── planner.py # Planejador (Gemini) │ │ ├── schema.py # Extração de schema (SQLite) @@ -134,7 +136,8 @@ TextToInsight/ │ │ │ ├── code_agent.py # Geração SQL (Gemini) │ │ │ └── code_sql.py # Validação + execução SQL │ │ ├── sandbox.py # Executor SQL (banco real) -│ │ └── critic.py # Avaliador (Gemini) +│ │ ├── critic.py # Avaliador (Gemini) +│ │ └── response.py # Resposta natural final │ └── routers/ │ └── edges.py # Roteadores condicionais └── tests/ @@ -209,3 +212,6 @@ Quota da API Gemini esgotada. Aguardar reset ou verificar em https://ai.dev/rate **Grafo entra em loop infinito** O sistema limita a 3 tentativas via `roteador_sandbox`. Se persistir, verificar se o status retornado pelos nós é um valor válido de `StatusExecucao`. + +**Validação de import do grafo falha com `grafo_text_to_insight`** +O módulo atual expõe a classe `Graph` (não um singleton global). Use o comando de validação da seção Setup. diff --git a/INDICE.py b/INDICE.py index 4ba0f76..9b71553 100644 --- a/INDICE.py +++ b/INDICE.py @@ -3,18 +3,18 @@ ╔════════════════════════════════════════════════════════════════════════════╗ ║ PROJETO TEXT-TO-INSIGHT ║ -║ Supervisor/Hierarchical Agent com LangGraph ║ -║ Autor: Jonas Melo | Versão: 0.1.0 Alpha | Status: Esqueleto ║ +║ Supervisor/Hierarchical Agent com LangGraph + HITL + Métricas ║ +║ Autor: Jonas Melo | Versão: 0.2.0 Alpha | Status: Fluxo Ativo ║ ╚════════════════════════════════════════════════════════════════════════════╝ ESTRUTURA DE DIRETÓRIOS: ═══════════════════════ -projeto_raia/ (Raiz do projeto) +TextToInsight/ (Raiz do projeto) │ ├── 📄 README.md ⭐ COMECE AQUI - Documentação Principal ├── 📄 ARQUITETURA.md Detalhamento técnico da arquitetura -├── 📄 DESENVOLVIMENTO.md Guia de setup e desenvolvimentyo local +├── 📄 DESENVOLVIMENTO.md Guia de setup e desenvolvimento local │ ├── 🔧 pyproject.toml Metadados e dependências do projeto ├── 📋 requirements.txt Dependências pip @@ -27,12 +27,17 @@ ├── __init__.py Package root ├── state.py ⭐ TypedDict EstadoTextToInsight ├── graph.py ⭐ Grafo compilado (entry point) + ├── model_selection.py Seleção de modelo/provedor LLM + ├── utils.py Telemetria de tokens e latência │ ├── 📁 nodes/ Nós do grafo │ ├── __init__.py │ ├── planner.py 🧠 Nó: Planejador (Supervisor) + │ ├── response.py 💬 Nó: Resposta Natural Final │ ├── schema.py 📊 Nó: Extrator de Schema - │ ├── code_agent.py 💻 Nó: Gerador de Código + │ ├── 📁 code_agent/ + │ │ ├── code_agent.py 💻 Nó: Gerador de SQL + │ │ └── code_sql.py 🔐 Validação + Execução SQL segura │ ├── sandbox.py 🏖️ Nó: Executor Seguro │ └── critic.py 🎯 Nó: Avaliador de Qualidade │ @@ -62,12 +67,13 @@ O QUE FOI CRIADO: ═════════════════ -✅ ESTRUTURA: 18 arquivos criados com tipagens, imports e estrutura completa -✅ ESTADO: TypedDict EstadoTextToInsight com 7 campos essenciais -✅ 5 NÓS: Planejador, Schema, AgenteCódigo, Sandbox, Crítico -✅ 2 ROTEADORES: Roteador Sandbox, Roteador Planejador (+ Crítico integrado) -✅ GRAFO COMPILADO: StateGraph com add_node, add_edge, add_conditional_edges +✅ ESTRUTURA: Projeto modular com src/, nós, roteadores e suíte de testes em 3 camadas +✅ ESTADO: TypedDict EstadoTextToInsight com campos de SQL, HITL, resposta e telemetria +✅ 7 NÓS: Planejador, EsperaHumana, Schema, AgenteCódigo, Sandbox, Crítico, Resposta +✅ 3 ROTEADORES: Sandbox, Planejador e Crítico +✅ GRAFO COMPILADO: StateGraph + MemorySaver + interrupt_before para HITL ✅ DOCUMENTAÇÃO: 3 guias: README, ARQUITETURA, DESENVOLVIMENTO +✅ TELEMETRIA: Tokens (input/output/total), tentativas e latência em CSV ✅ TODA EM PT-BR: Código, variáveis, docstrings, comentários ═══════════════════════════════════════════════════════════════════════════════ @@ -75,12 +81,12 @@ ESTATÍSTICAS: ═════════════ -📊 Linhas de Código: ~1.200+ (sem testes) -📚 Arquivos Python: 10 (src/ + main.py) -📖 Documentação: 3 arquivos markdown (~2.000 linhas) -🔄 Fluxos de Grafo: 3+ cenários possíveis +📊 Linhas de Código: ~1.400+ (incluindo nós, roteadores e utilitários) +📚 Arquivos Python: 15+ (src/ + main.py + testes) +📖 Documentação: 3 guias principais +🔄 Fluxos de Grafo: 4+ cenários (normal, retry, HITL, bloqueado_hitl) 🧠 Tentativas max: 3 por padrão (configurável) -⏱️ Status possíveis: 10+ diferentes (initiado, schema_obtido, codigo_ok, etc) +⏱️ Status possíveis: 10 tipados + operacionais (aguardando_input, bloqueado_hitl) ═══════════════════════════════════════════════════════════════════════════════ @@ -105,7 +111,7 @@ 2. Executar: python main.py "Sua pergunta" 3. Rastrear logs nos outputs dos nós 4. Estudar ARQUITETURA.md para entender fluxos -5. Modificar nós mockados para suas necessidades +5. Testar o modo HITL: --hitl on e --hitl off ═══════════════════════════════════════════════════════════════════════════════ @@ -113,12 +119,13 @@ ════════════════════ ✓ Type hints completos (TypedDict, Literal, etc) -✓ Docstrings em todos os funções +✓ Docstrings nas principais funções ✓ Comentários explicativos em código crítico ✓ Imports organizados ✓ Nomes descritivos em português ✓ Separação clara de responsabilidades ✓ Estrutura pronta para testes +✓ Métricas registradas por execução (tokens + latência) ═══════════════════════════════════════════════════════════════════════════════ diff --git a/README.md b/README.md index 00ab3a2..8a5bc9f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Text-to-Insight -Sistema de agentes baseado em **LangGraph** que transforma perguntas em linguagem natural em consultas SQL, executa contra um banco SQLite real e valida os resultados automaticamente. +Sistema de agentes baseado em **LangGraph** que transforma perguntas em linguagem natural em consultas SQL, executa contra um banco SQLite real, valida os resultados automaticamente e gera uma resposta final em linguagem natural. ## Como funciona @@ -17,10 +17,13 @@ Pergunta do usuário | [Crítico] -- Avalia se o resultado responde à pergunta (LLM) | - Aprovado? -- Sim --> FIM + Aprovado? -- Sim --> [Resposta Natural] --> FIM -- Não --> Volta ao Planejador (retry) ``` +Fluxo paralelo: +- Se faltar contexto humano: Planejador -> Espera Humana -> Planejador + ## Requisitos - Python 3.10+ @@ -45,10 +48,18 @@ echo "GOOGLE_API_KEY=sua_chave_aqui" > .env # Pergunta via linha de comando python main.py "Quantos pedidos existem no banco?" +# Forçando HITL ligado +python main.py --hitl on "Quais categorias vendem mais?" + +# Execução não interativa +python main.py --hitl off "Quais categorias vendem mais?" + # Sem argumento usa pergunta padrão python main.py ``` +Por padrão, o sistema roda com `--hitl on`. + ## Testes O projeto possui 3 camadas de teste: @@ -76,6 +87,8 @@ TextToInsight/ ├── src/ │ ├── state.py # Estado compartilhado (TypedDict) │ ├── graph.py # Grafo LangGraph compilado +│ ├── model_selection.py # Seleção de provedor/modelo LLM +│ ├── utils.py # Métricas de tokens e latência │ ├── nodes/ │ │ ├── planner.py # Planejador (LLM) │ │ ├── schema.py # Extração de schema (SQLite) @@ -83,7 +96,8 @@ TextToInsight/ │ │ │ ├── code_agent.py # Geração de SQL (LLM) │ │ │ └── code_sql.py # Validação e execução de SQL │ │ ├── sandbox.py # Executor de SQL (banco real) -│ │ └── critic.py # Avaliador de qualidade (LLM) +│ │ ├── critic.py # Avaliador de qualidade (LLM) +│ │ └── response.py # Resposta final em linguagem natural │ └── routers/ │ └── edges.py # Roteadores condicionais └── tests/ @@ -99,12 +113,17 @@ langgraph>=0.2.0 langchain>=0.2.0 langchain-core>=0.2.0 langchain-google-genai>=2.0.0 +langchain-openai python-dotenv>=1.0.0 +pytest>=9.0.2 +pytest-recording>=0.13.0 +pytest-timeout>=2.3.0 ``` ## Stack - **LangGraph** para orquestração do grafo de agentes -- **Google Gemini** (gemini-2.5-flash) para chamadas LLM +- **Google Gemini** (gemini-2.5-flash, padrão atual) para chamadas LLM +- **OpenAI Chat Models** suportados via seletor de modelo - **SQLite** como banco de dados (modo read-only) -- **pytest** para testes +- **pytest + VCR** para testes determinísticos com gravação de chamadas From 0a3351ac5f450b931ec56e379e3f581656b20e0b Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Wed, 22 Apr 2026 22:36:02 -0300 Subject: [PATCH 8/9] =?UTF-8?q?Refactor:=20consolida=20namespace=20text=5F?= =?UTF-8?q?to=5Finsight,=20unifica=20runtime=20e=20API=20p=C3=BAblica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Este commit transforma a base de código em uma biblioteca semântica e oficial, eliminando duplicidades e formalizando contratos de execução, além de preparar o pacote para distribuição. Principais alterações: - Namespace e Empacotamento: Código base movido de `src` para `text_to_insight`. O pyproject.toml foi configurado para varredura do namespace oficial e criação do entrypoint de console `text-to-insight`. - Unificação de Runtime: Lógica antes dividida entre main e InsightEngine foi centralizada em `runtime.py`, que agora cuida do estado, loop do grafo, métricas e controle central de HITL (ligado/desligado/pausado). - Engine e CLI: Criação de uma CLI oficial (`cli.py`) suportando thread-id e db-path. A `InsightEngine` orquestra a execução via runtime (com métodos `run` e `resume`). O arquivo `main.py` virou apenas um adaptador fino da CLI. - API Pública: Congelada no `__init__.py`, expondo apenas as três interfaces essenciais: InsightEngine, Graph e EstadoTextToInsight. - Qualidade e Testes: Imports e assinaturas ajustados. Adição de novos testes de integração da Engine (fim a fim, com e sem HITL). Adição de `test_real_api_smoke.py` com o novo marker `real_api` no pytest.ini para monitoramento de provider/model drift. - Documentação: README.md, DESENVOLVIMENTO.md e ARQUITETURA.md atualizados para refletir a nova estrutura de biblioteca, estratégias de teste, CI híbrida e contratos de HITL. --- .github/workflows/ci.yml | 117 +++++++++ ARQUITETURA.md | 244 ++++++++--------- DESENVOLVIMENTO.md | 245 +++++++----------- README.md | 187 ++++++------- main.py | 179 +------------ pyproject.toml | 13 +- pytest.ini | 3 + requirements.txt | 2 +- src/InsightEngine.py | 179 ------------- src/__init__.py | 13 - tests/conftest.py | 71 ++++- tests/test_componentes.py | 44 ++-- tests/test_integracao.py | 6 +- tests/test_main_engine_integracao.py | 123 +++++++++ tests/test_nodes.py | 34 ++- tests/test_real_api_smoke.py | 21 ++ text_to_insight/InsightEngine.py | 155 +++++++++++ text_to_insight/__init__.py | 7 + text_to_insight/cli.py | 107 ++++++++ {src => text_to_insight}/graph.py | 7 +- {src => text_to_insight}/model_selection.py | 0 {src => text_to_insight}/nodes/__init__.py | 58 ++--- .../nodes/code_agent/__init__.py | 0 .../nodes/code_agent/code_agent.py | 0 .../nodes/code_agent/code_sql.py | 0 {src => text_to_insight}/nodes/critic.py | 0 {src => text_to_insight}/nodes/planner.py | 0 {src => text_to_insight}/nodes/response.py | 0 {src => text_to_insight}/nodes/sandbox.py | 0 {src => text_to_insight}/nodes/schema.py | 0 {src => text_to_insight}/routers/__init__.py | 0 {src => text_to_insight}/routers/edges.py | 0 text_to_insight/runtime.py | 150 +++++++++++ {src => text_to_insight}/state.py | 0 {src => text_to_insight}/utils.py | 0 35 files changed, 1136 insertions(+), 829 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pytest.ini delete mode 100644 src/InsightEngine.py delete mode 100644 src/__init__.py create mode 100644 tests/test_main_engine_integracao.py create mode 100644 tests/test_real_api_smoke.py create mode 100644 text_to_insight/InsightEngine.py create mode 100644 text_to_insight/__init__.py create mode 100644 text_to_insight/cli.py rename {src => text_to_insight}/graph.py (94%) rename {src => text_to_insight}/model_selection.py (100%) rename {src => text_to_insight}/nodes/__init__.py (96%) rename {src => text_to_insight}/nodes/code_agent/__init__.py (100%) rename {src => text_to_insight}/nodes/code_agent/code_agent.py (100%) rename {src => text_to_insight}/nodes/code_agent/code_sql.py (100%) rename {src => text_to_insight}/nodes/critic.py (100%) rename {src => text_to_insight}/nodes/planner.py (100%) rename {src => text_to_insight}/nodes/response.py (100%) rename {src => text_to_insight}/nodes/sandbox.py (100%) rename {src => text_to_insight}/nodes/schema.py (100%) rename {src => text_to_insight}/routers/__init__.py (100%) rename {src => text_to_insight}/routers/edges.py (100%) create mode 100644 text_to_insight/runtime.py rename {src => text_to_insight}/state.py (100%) rename {src => text_to_insight}/utils.py (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..140a064 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,117 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - dev + workflow_dispatch: + schedule: + - cron: "0 3 * * *" + +jobs: + tests-vcr: + name: Tests (VCR deterministic) + runs-on: ubuntu-latest + env: + GOOGLE_API_KEY: dummy-key + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Run deterministic test layers + run: | + pytest \ + tests/test_componentes.py \ + tests/test_nodes.py \ + tests/test_integracao.py \ + tests/test_main_engine_integracao.py \ + -v -s --record-mode=none -m "not real_api" + + record-vcr-cassettes: + name: Record VCR cassettes (manual) + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + env: + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Validate API key + run: | + if [[ -z "${GOOGLE_API_KEY}" ]]; then + echo "GOOGLE_API_KEY secret ausente; nao e possivel gravar cassetes." + exit 1 + fi + + - name: Record or update VCR cassettes + run: | + pytest \ + tests/test_nodes.py \ + tests/test_integracao.py \ + -v -s --record-mode=new_episodes -m "not real_api" + + - name: Show cassette status + run: | + git status -- tests/cassettes || true + + - name: Upload recorded cassettes artifact + uses: actions/upload-artifact@v4 + with: + name: vcr-cassettes-${{ github.run_id }} + path: tests/cassettes + if-no-files-found: warn + + tests-real-api: + name: Tests (real API optional) + runs-on: ubuntu-latest + needs: tests-vcr + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + env: + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Run optional real API smoke + run: | + if [[ -z "${GOOGLE_API_KEY}" ]]; then + echo "GOOGLE_API_KEY secret ausente; pulando job real_api." + exit 0 + fi + pytest tests/test_real_api_smoke.py -v -s -m real_api diff --git a/ARQUITETURA.md b/ARQUITETURA.md index ba88c58..3226d33 100644 --- a/ARQUITETURA.md +++ b/ARQUITETURA.md @@ -1,181 +1,159 @@ # Arquitetura - Text-to-Insight -## Visão geral +## Visao geral -O sistema usa um grafo de agentes (LangGraph) com 7 nós e 3 roteadores condicionais. Quatro nós podem usar LLM (Planejador, Agente de Código, Crítico e Resposta), um usa introspecção SQLite, um executa SQL diretamente e um funciona como breakpoint estrutural para HITL. +O sistema combina: -``` +- um grafo LangGraph com 7 nos; +- uma camada de runtime compartilhada (`text_to_insight/runtime.py`); +- duas interfaces de entrada: biblioteca (`InsightEngine`) e CLI (`main.py` -> `text_to_insight/cli.py`). + +Fluxo principal: + +```text START - | -[Planejador] ---------> decide estratégia (LLM) - | - |-- schema vazio? --> [Schema] --> extrai metadados do banco - | | - | v - |--------------------- volta ao Planejador - | - |-- precisa ajuda? --> [Espera Humana] --> interrompe fluxo (HITL) - | | - | v - |--------------------- volta ao Planejador - | - |-- pronto? --------> [Agente Código] --> gera SQL (LLM) - | | - | v - | [Executor] --> executa SQL no banco real - | | - | v - | [Crítico] --> avalia resultado (LLM) - | | - | aprovado? -- Sim --> [Resposta] --> END - | -- Não --> volta ao Planejador - | - |-- aprovado? ------> END + -> Planejador + -> Schema (quando necessario) + -> Agente de Codigo + -> Executor + -> Critico + -> Resposta +END ``` -## Nós +Fluxo alternativo HITL: -### Planejador (`src/nodes/planner.py`) +```text +Planejador -> Espera Humana (interrupt_before) -> Planejador +``` -Decide a próxima etapa do fluxo. +## Camadas -- **Sem schema**: retorna `"aguardando_schema"` (determinístico, sem API) -- **Com schema**: chama Gemini para decidir entre `"pronto_codificacao"`, `"revisando_estrategia"` ou `"necessita_ajuda"` (quando HITL está ativo) -- **Já aprovado**: mantém `"aprovado"` +### 1) Orquestracao do grafo -Entrada: `pergunta_usuario`, `historico_conversa`, `contexto_schema`, `feedback_critico`, `status`, `erro_execucao`, `tentativas_loop` -Saída: `status`, `espera_humana`, `pergunta_ao_usuario`, `tentativas_loop`, métricas de token +Arquivo: `text_to_insight/graph.py` -### Espera Humana (`src/graph.py`) +Responsavel por: -Nó estrutural para Human-in-the-Loop (HITL). +- criar os nos; +- definir arestas fixas e condicionais; +- compilar com `MemorySaver`; +- interromper antes de `espera_humana` para suportar HITL. -- Não transforma dados -- Existe para permitir interrupção controlada via `interrupt_before=["espera_humana"]` -- Após coleta de input no terminal, o fluxo retorna ao Planejador +### 2) Runtime compartilhado -Entrada: estado atual completo -Saída: estado inalterado +Arquivo: `text_to_insight/runtime.py` -### Schema (`src/nodes/schema.py`) +Responsavel por: -Extrai metadados estruturais do banco SQLite via introspecção (PRAGMA). +- construir estado inicial; +- executar loop de stream ate fim/pausa; +- tratar bloqueio HITL quando `hitl=False`; +- registrar resposta humana na thread; +- persistir metricas em CSV; +- exibir resultado final em formato padrao. -- Lista tabelas, colunas, tipos, constraints e foreign keys -- Conexão read-only (`?mode=ro`) -- Sem chamada de API +### 3) API publica da biblioteca -Entrada: `db_path` -Saída: `contexto_schema`, `status` +Arquivos: -### Agente de Código (`src/nodes/code_agent/code_agent.py`) +- `text_to_insight/InsightEngine.py` +- `text_to_insight/__init__.py` -Gera SQL a partir da pergunta + schema usando Gemini. +Contrato estavel: -- Prompt inclui schema completo, pergunta do usuário e feedback anterior (se retry) -- Extrai SQL pura da resposta (remove markdown se presente) -- Incrementa `tentativas_loop` +- `run(thread_id, query)`; +- `resume(thread_id, user_response)`; +- `get_insight(...)` como base de compatibilidade. -Entrada: `pergunta_usuario`, `contexto_schema`, `feedback_critico` -Saída: `sql_gerada`, `status`, `tentativas_loop`, métricas de token +API publica exportada: -Utilidades auxiliares em `code_sql.py`: -- `validar_sql_segura()`: bloqueia INSERT, UPDATE, DELETE, DROP, etc. Permite apenas SELECT e WITH. -- `executar_sql_sqlite()`: executa SQL em modo read-only, retorna resultado estruturado. +- `InsightEngine` +- `Graph` +- `EstadoTextToInsight` -### Executor (`src/nodes/sandbox.py`) +### 4) Adaptador CLI -Valida e executa a SQL gerada contra o banco real. +Arquivos: -- Usa `executar_sql_sqlite()` de `code_sql.py` -- Retorna preview de até 30 linhas + total de linhas -- Sem chamada de API +- `text_to_insight/cli.py` +- `main.py` -Entrada: `sql_gerada`, `db_path` -Saída: `linhas_resultado_preview`, `total_linhas_resultado`, `saida_terminal`, `erro_execucao`, `status` +`main.py` e um wrapper fino da CLI do pacote. -### Crítico (`src/nodes/critic.py`) +## Nos do grafo -Avalia se o resultado responde à pergunta original usando Gemini. +### Planejador (`text_to_insight/nodes/planner.py`) -- Se houve erro de execução: reprova direto sem chamar API -- Se execução OK: chama Gemini com pergunta + SQL + preview dos resultados -- Retorna veredito (`"aprovado"` / `"reprovado"`) e feedback textual +- sem schema: `aguardando_schema` (deterministico) +- com schema: decide entre `pronto_codificacao`, `revisando_estrategia` ou `necessita_ajuda` +- opcionalmente ativa HITL (`espera_humana=True`) -Entrada: `pergunta_usuario`, `sql_gerada`, `linhas_resultado_preview`, `status` -Saída: `feedback_critico`, `status`, métricas de token +### Espera Humana (`text_to_insight/graph.py`) -### Resposta (`src/nodes/response.py`) +- no estrutural sem transformacao de estado +- usado como ponto de interrupcao para retomada por `thread_id` -Gera resposta final em linguagem natural quando o resultado foi aprovado. +### Schema (`text_to_insight/nodes/schema.py`) -- Só executa geração textual quando `status == "aprovado"` -- Em `pytest`/`CI`, usa fallback determinístico sem API -- Em execução normal, usa LLM para sumarizar pergunta + resultado +- introspeccao SQLite real em modo read-only +- extrai tabelas, colunas e relacionamentos -Entrada: `pergunta_usuario`, `sql_gerada`, `linhas_resultado_preview`, `total_linhas_resultado`, `saida_terminal`, `status` -Saída: `resposta_natural`, `status`, métricas de token +### Agente de Codigo (`text_to_insight/nodes/code_agent/code_agent.py`) -## Roteadores (`src/routers/edges.py`) +- gera SQL com LLM a partir de pergunta + schema + feedback -### Após Executor (`roteador_sandbox`) -- `exec_ok` → Crítico -- `exec_erro` + tentativas < 3 → Planejador -- Caso contrário → Planejador +### Executor (`text_to_insight/nodes/sandbox.py`) -### Após Planejador (`roteador_planejador`) -- `espera_humana=True` → Espera Humana -- Schema vazio → Schema -- `pronto_codificacao` ou `revisando_estrategia` → Agente Código -- `aprovado` → END -- Default → Planejador +- valida SQL e executa via `code_sql.py` +- devolve preview + total de linhas -### Após Crítico (`roteador_critico`, definido em `graph.py`) -- `aprovado` → Resposta -- Caso contrário → Planejador +### Critico (`text_to_insight/nodes/critic.py`) -## Estado compartilhado (`src/state.py`) +- valida se resultado responde a pergunta +- reprova automaticamente em erro de execucao -```python -# Campos obrigatórios -pergunta_usuario: str # Pergunta em linguagem natural -db_path: str # Caminho para o SQLite +### Resposta (`text_to_insight/nodes/response.py`) -# Campos preenchidos pelos nós -contexto_schema: str # Schema textual extraído -sql_gerada: str # SQL produzida pelo agente -linhas_resultado_preview: list[dict] # Amostra de até 30 linhas -total_linhas_resultado: int # Total de linhas retornadas -erro_execucao: str # Mensagem de erro (se houver) -saida_terminal: str # Saída resumida da execução -feedback_critico: str # Feedback do crítico -espera_humana: bool # Flag para interrupção HITL -pergunta_ao_usuario: str # Pergunta que será exibida no terminal -historico_conversa: list[tuple[str, str]] # Histórico humano/agente -resposta_natural: str # Resposta final em linguagem natural -status: StatusExecucao # Estágio atual do fluxo -tentativas_loop: int # Contador de tentativas +- gera resposta natural final quando status aprovado -# Telemetria -tokens_input: int -tokens_output: int -tokens_total: int -``` +## Roteadores -Status possíveis: `iniciado`, `aguardando_schema`, `schema_obtido`, `pronto_codificacao`, `sql_gerada`, `exec_ok`, `exec_erro`, `revisando_estrategia`, `aprovado`, `reprovado`. +Arquivo: `text_to_insight/routers/edges.py` -Status operacionais adicionais em runtime: `aguardando_input` (pedido de HITL) e `bloqueado_hitl` (quando a CLI roda com `--hitl off` e há necessidade de intervenção humana). +- `roteador_sandbox`: controla retry apos execucao +- `roteador_planejador`: decide schema, codificacao, HITL ou fim +- `roteador_critico` (interno em `graph.py`): aprovado -> resposta; senao -> planejador -## Fluxo típico +## Estado compartilhado -``` -1. START → Planejador (schema vazio → "aguardando_schema") -2. → Schema (extrai metadados do SQLite) -3. → Planejador reavalia estratégia -4. → (Opcional) Espera Humana se houver ambiguidade/falta de contexto -5. → Agente Código (gera SQL com Gemini) -6. → Executor (executa SQL no banco) -7. → Crítico (avalia resultado com Gemini) -8. → Se aprovado: Resposta → END - Se reprovado: volta ao Planejador com feedback -``` +Arquivo: `text_to_insight/state.py` + +Campos obrigatorios: + +- `pergunta_usuario` +- `db_path` + +Campos principais do fluxo: + +- `contexto_schema`, `sql_gerada`, `linhas_resultado_preview`, `total_linhas_resultado` +- `erro_execucao`, `saida_terminal`, `feedback_critico`, `resposta_natural` +- `status`, `tentativas_loop`, `historico_conversa`, `espera_humana`, `pergunta_ao_usuario` +- telemetria: `tokens_input`, `tokens_output`, `tokens_total` + +## Status operacionais + +No runtime/engine podem aparecer: + +- `AWAITING_USER` (pausa HITL aguardando resposta); +- `bloqueado_hitl` (quando HITL esta desligado e o planejamento pede input humano). + +## Politica VCR + +Os testes marcados com `@pytest.mark.vcr` usam cassetes em `tests/cassettes/`. + +- no fluxo padrao de PR/CI: `--record-mode=none` (somente replay deterministico); +- para gravar ou atualizar cassetes: `--record-mode=new_episodes`; +- apos gravacao: execute novamente com `--record-mode=none` para validar reproducibilidade. + +Na CI existe um job manual `record-vcr-cassettes` (workflow_dispatch) para gravacao/atualizacao controlada. diff --git a/DESENVOLVIMENTO.md b/DESENVOLVIMENTO.md index f205582..8811495 100644 --- a/DESENVOLVIMENTO.md +++ b/DESENVOLVIMENTO.md @@ -1,217 +1,162 @@ # Guia de Desenvolvimento - Text-to-Insight -## Setup +## Namespace oficial -### Pré-requisitos +O codigo-fonte da biblioteca esta no pacote `text_to_insight`. +Imports antigos via `src` nao devem mais ser usados. + +## Setup local + +### Pre-requisitos - Python 3.10+ -- conda (ou pip + venv) -- Git -- Chave de API do Google Gemini +- venv/conda +- chave de API (Gemini ou OpenAI, conforme modelo escolhido) -### Instalação +### Instalacao ```bash -# Criar e ativar ambiente -conda create -n textToInsight python=3.11 -conda activate textToInsight +python -m venv .venv +source .venv/bin/activate -# Instalar dependências pip install -r requirements.txt - -# Instalar ferramentas de teste -pip install pytest pytest-timeout +pip install -e . ``` -### Configuração - -Criar `.env` na raiz do projeto: +### Configuracao -``` -GOOGLE_API_KEY=sua_chave_aqui +```bash +echo "GOOGLE_API_KEY=sua_chave" > .env ``` -Colocar o banco SQLite em `data/` (ex: `data/olist_relational.db`). +Banco SQLite esperado por padrao: `data/olist_relational.db`. -### Validar instalação +### Verificação Rápida (Smoke) de import ```bash -python -c "from src.graph import Graph; print('OK')" +python -c "from text_to_insight import InsightEngine, Graph; print('OK')" ``` -## Executando - -```bash -# Com pergunta customizada -python main.py "Quantos pedidos existem no banco?" +## Contrato de execução -# Com pergunta padrão -python main.py -``` +API estável da engine: -### Tutorial da flag `--hitl` +- `run(thread_id, query)` para iniciar; +- `resume(thread_id, user_response)` para retomar HITL; +- `get_insight(...)` mantido como API base. -A CLI agora aceita o toggle `--hitl {on,off}` para controlar o modo Human-in-the-Loop. +Criterios minimos: -#### 1) Comportamento padrão (sem informar flag) +- fluxo completo do grafo; +- HITL on/off; +- retomada por `thread_id`; +- gravacao de metricas em CSV. -Se você não passar `--hitl`, o modo fica **ativado automaticamente** (`on`). +## Execucao ```bash -python main.py "Quantos pedidos existem no banco?" -``` - -#### 2) Forçar HITL ligado - -Use quando quiser interação humana no terminal caso o planejador peça esclarecimentos. +# adaptador local +python main.py --hitl on "Quantos pedidos existem no banco?" -```bash -python main.py --hitl on "Quais foram os principais fatores de queda no lucro?" +# biblioteca instalada (entrypoint) +text-to-insight --hitl off "Quais categorias vendem mais?" ``` -Quando o fluxo precisar de ajuda humana, o terminal pergunta e aguarda input: +## Estrutura relevante ```text -[HITL]: -[RESPOSTA USUARIO]: -``` - -#### 3) Desligar HITL - -Use para execução não interativa (scripts, pipelines, CI, etc.). - -```bash -python main.py --hitl off "Quais foram os principais fatores de queda no lucro?" +text_to_insight/ + InsightEngine.py + cli.py + runtime.py + graph.py + state.py + model_selection.py + nodes/ + routers/ +tests/ + test_componentes.py + test_nodes.py + test_integracao.py + test_main_engine_integracao.py + test_real_api_smoke.py ``` -Se o grafo chegar em `espera_humana` com `--hitl off`: -- o sistema **não** chama `input()`; -- encerra a execução com status `bloqueado_hitl`; -- registra erro explicando que havia necessidade de intervenção humana com HITL desativado. - ## Testes -3 camadas, do mais rápido ao mais completo: +### Camadas ```bash -# Camada 1: Componentes (sem API, ~1s) -# Testa: validação SQL, execução SQL, schema, executor, routers +# camada 1 - componentes deterministicos pytest tests/test_componentes.py -v -s -# Camada 2: Nós individuais (com API, ~30s) -# Testa cada nó isoladamente com estado manual -pytest tests/test_nodes.py -v -s - -# Camada 3: Grafo completo (com API, ~1-2min) -# Testa o pipeline inteiro end-to-end -pytest tests/test_integracao.py -v -s +# camada 2 - nos com VCR (replay, sem gravar novas cassetes) +pytest tests/test_nodes.py -v -s --record-mode=none -# Tudo de uma vez -pytest tests/ -v -s -``` +# camada 2 - nos com VCR (gravar/atualizar cassetes) +pytest tests/test_nodes.py -v -s --record-mode=new_episodes -Se um teste falha: -- Falha na camada 1 → lógica determinística quebrou -- Falha na camada 2 → o nó específico que falhou está com problema -- Camada 2 passa mas camada 3 falha → problema nos roteadores ou na conexão entre nós +# camada 3 - integracao do grafo com VCR (replay, sem gravar) +pytest tests/test_integracao.py -v -s --record-mode=none -## Estrutura de arquivos +# camada 3 - integracao do grafo com VCR (gravar/atualizar cassetes) +pytest tests/test_integracao.py -v -s --record-mode=new_episodes +# integracao main + InsightEngine +pytest tests/test_main_engine_integracao.py -v -s ``` -TextToInsight/ -├── main.py # Ponto de entrada CLI -├── .env # GOOGLE_API_KEY (não commitar) -├── requirements.txt -├── data/ -│ └── olist_relational.db # Banco SQLite -├── src/ -│ ├── state.py # EstadoTextToInsight (TypedDict) -│ ├── graph.py # Grafo LangGraph -│ ├── model_selection.py # Seleção de modelo/provedor LLM -│ ├── utils.py # Tokens e métricas CSV -│ ├── nodes/ -│ │ ├── planner.py # Planejador (Gemini) -│ │ ├── schema.py # Extração de schema (SQLite) -│ │ ├── code_agent/ -│ │ │ ├── code_agent.py # Geração SQL (Gemini) -│ │ │ └── code_sql.py # Validação + execução SQL -│ │ ├── sandbox.py # Executor SQL (banco real) -│ │ ├── critic.py # Avaliador (Gemini) -│ │ └── response.py # Resposta natural final -│ └── routers/ -│ └── edges.py # Roteadores condicionais -└── tests/ - ├── test_componentes.py # Sem API - ├── test_nodes.py # Com API, nó a nó - └── test_integracao.py # Com API, grafo completo -``` - -## Criando um novo nó - -1. Criar `src/nodes/novo_no.py`: -```python -from ..state import EstadoTextToInsight +### Gravacao de cassetes VCR (fluxo recomendado) -def nos_nodo_novo(estado: EstadoTextToInsight) -> dict: - # ler do estado - valor = estado.get("algum_campo", "") +Use este fluxo quando mudar prompts, comportamento de nos ou quando adicionar testes com `@pytest.mark.vcr`: - # processar +```bash +# 1) Grave/atualize as cassetes +pytest tests/test_nodes.py tests/test_integracao.py -v -s --record-mode=new_episodes - # retornar atualizações - return { - "campo_atualizado": resultado, - "status": "novo_status", - } +# 2) Rode em replay para garantir determinismo +pytest tests/test_nodes.py tests/test_integracao.py -v -s --record-mode=none ``` -2. Registrar em `src/nodes/__init__.py` -3. Adicionar ao grafo em `src/graph.py` -4. Criar teste em `tests/` +Observacoes: -## Criando um novo roteador +- cassetes ficam em `tests/cassettes/`; +- `new_episodes` grava apenas chamadas que ainda nao existem no YAML; +- `none` falha se faltar cassette, garantindo execucao reproduzivel. -```python -from typing import Literal -from ..state import EstadoTextToInsight +### Drift provider/modelo (opcional) para verificar se API real ainda responde conforme esperado: -def roteador_novo(estado: EstadoTextToInsight) -> Literal["no_a", "no_b"]: - if estado.get("status") == "condicao": - return "no_a" - return "no_b" +```bash +pytest tests/test_real_api_smoke.py -v -s -m real_api ``` -Registrar com `add_conditional_edges()` em `graph.py`. +## CI hibrida -## Git Flow +Arquivo: `.github/workflows/ci.yml` -``` -main ← versão estável (v0.0.1) - └── dev ← desenvolvimento - └── feature_ ← features individuais -``` +- job padrao deterministico em PR/push (VCR + `--record-mode=none`); +- job manual `record-vcr-cassettes` em `workflow_dispatch` para gravar/atualizar cassetes com API real; +- job opcional real API em `workflow_dispatch` e `schedule`. -Branches de hotfix saem direto de `main`. +## Build e distribuicao -## Variáveis de ambiente +```bash +python -m build -| Variável | Descrição | -|---|---| -| `GOOGLE_API_KEY` | Chave da API do Google Gemini | +python -m venv .venv-smoke +source .venv-smoke/bin/activate +pip install dist/*.whl +python -c "from text_to_insight import InsightEngine; print('wheel_ok')" +``` -## Troubleshooting +## Troubleshooting (diagnóstico de falhas) rápido -**`ModuleNotFoundError: No module named 'langgraph'`** +`ModuleNotFoundError`: ```bash pip install -r requirements.txt +pip install -e . ``` -**`429 RESOURCE_EXHAUSTED`** -Quota da API Gemini esgotada. Aguardar reset ou verificar em https://ai.dev/rate-limit - -**Grafo entra em loop infinito** -O sistema limita a 3 tentativas via `roteador_sandbox`. Se persistir, verificar se o status retornado pelos nós é um valor válido de `StatusExecucao`. - -**Validação de import do grafo falha com `grafo_text_to_insight`** -O módulo atual expõe a classe `Graph` (não um singleton global). Use o comando de validação da seção Setup. +`429 RESOURCE_EXHAUSTED`: +- aguardar reset de quota; +- preferir testes com VCR no dia a dia. diff --git a/README.md b/README.md index 8a5bc9f..24bd3dd 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,136 @@ # Text-to-Insight -Sistema de agentes baseado em **LangGraph** que transforma perguntas em linguagem natural em consultas SQL, executa contra um banco SQLite real, valida os resultados automaticamente e gera uma resposta final em linguagem natural. +Biblioteca Python para transformar perguntas em linguagem natural em SQL executada com seguranca em SQLite, com avaliacao automatica e resposta final em linguagem natural. -## Como funciona +O namespace oficial do pacote e `text_to_insight`. -``` -Pergunta do usuário - | - [Planejador] -- Decide a estratégia (LLM) - | - [Schema] -- Extrai metadados do banco (SQLite) - | - [Agente Código] -- Gera SQL a partir da pergunta + schema (LLM) - | - [Executor] -- Executa SQL no banco real (read-only) - | - [Crítico] -- Avalia se o resultado responde à pergunta (LLM) - | - Aprovado? -- Sim --> [Resposta Natural] --> FIM - -- Não --> Volta ao Planejador (retry) -``` - -Fluxo paralelo: -- Se faltar contexto humano: Planejador -> Espera Humana -> Planejador +## Contrato minimo -## Requisitos +O runtime padrao garante: -- Python 3.10+ -- Conta Google com API Key para Gemini +- fluxo completo do grafo (planejador -> schema -> agente de codigo -> executor -> critico -> resposta); +- HITL ligado e desligado; +- retomada por `thread_id`; +- persistencia de metricas em `data/metricas_execucao.csv`. -## Setup +## Instalacao ```bash -# 1. Instalar dependências pip install -r requirements.txt +pip install -e . +``` + +Configure a chave da API: -# 2. Criar arquivo .env na raiz do projeto +```bash echo "GOOGLE_API_KEY=sua_chave_aqui" > .env +``` + +## Uso como biblioteca + +```python +from text_to_insight import InsightEngine -# 3. Colocar o banco SQLite em data/ -# (ex: data/olist_relational.db) +engine = InsightEngine( + api_key="...", + model="gemini-2.5-flash", + db_path="data/olist_relational.db", + hitl=True, +) + +resultado = engine.run( + thread_id="sessao_1", + query="Quantos pedidos existem no banco?", +) + +if resultado.get("status") == "AWAITING_USER": + resultado = engine.resume( + thread_id="sessao_1", + user_response="Pode assumir status entregue.", + ) ``` -## Uso +API publica congelada do pacote: -```bash -# Pergunta via linha de comando -python main.py "Quantos pedidos existem no banco?" +- `text_to_insight.InsightEngine` +- `text_to_insight.Graph` +- `text_to_insight.EstadoTextToInsight` + +Imports antigos via `src` nao sao mais suportados. -# Forçando HITL ligado +## Uso via CLI + +O arquivo `main.py` e um adaptador fino da CLI da biblioteca. + +```bash +# via adaptador local python main.py --hitl on "Quais categorias vendem mais?" -# Execução não interativa +# modo nao interativo python main.py --hitl off "Quais categorias vendem mais?" -# Sem argumento usa pergunta padrão -python main.py +# via entrypoint instalado pelo pacote +text-to-insight --hitl on "Quantos pedidos existem no banco?" ``` -Por padrão, o sistema roda com `--hitl on`. - ## Testes -O projeto possui 3 camadas de teste: +Camadas atuais: ```bash -# Camada 1: Componentes isolados (sem API, rápido) +# Camada 1 - componentes deterministicos (sem API) pytest tests/test_componentes.py -v -s -# Camada 2: Cada nó individualmente com API + banco real -pytest tests/test_nodes.py -v -s +# Camada 2 - nos individuais com VCR (replay, sem gravar) +pytest tests/test_nodes.py -v -s --record-mode=none -# Camada 3: Grafo completo end-to-end -pytest tests/test_integracao.py -v -s -``` +# Camada 2 - nos individuais com VCR (gravar/atualizar cassetes) +pytest tests/test_nodes.py -v -s --record-mode=new_episodes -## Estrutura +# Camada 3 - integracao do grafo com VCR (replay, sem gravar) +pytest tests/test_integracao.py -v -s --record-mode=none +# Camada 3 - integracao do grafo com VCR (gravar/atualizar cassetes) +pytest tests/test_integracao.py -v -s --record-mode=new_episodes + +# Integracao dedicada main + InsightEngine +pytest tests/test_main_engine_integracao.py -v -s ``` -TextToInsight/ -├── main.py # Ponto de entrada -├── .env # GOOGLE_API_KEY -├── requirements.txt # Dependências -├── data/ -│ └── olist_relational.db # Banco SQLite para análise -├── src/ -│ ├── state.py # Estado compartilhado (TypedDict) -│ ├── graph.py # Grafo LangGraph compilado -│ ├── model_selection.py # Seleção de provedor/modelo LLM -│ ├── utils.py # Métricas de tokens e latência -│ ├── nodes/ -│ │ ├── planner.py # Planejador (LLM) -│ │ ├── schema.py # Extração de schema (SQLite) -│ │ ├── code_agent/ -│ │ │ ├── code_agent.py # Geração de SQL (LLM) -│ │ │ └── code_sql.py # Validação e execução de SQL -│ │ ├── sandbox.py # Executor de SQL (banco real) -│ │ ├── critic.py # Avaliador de qualidade (LLM) -│ │ └── response.py # Resposta final em linguagem natural -│ └── routers/ -│ └── edges.py # Roteadores condicionais -└── tests/ - ├── test_componentes.py # Testes sem API - ├── test_nodes.py # Testes por nó com API - └── test_integracao.py # Teste do grafo completo -``` -## Dependências +Fluxo recomendado de gravacao VCR: + +```bash +# gravar/atualizar +pytest tests/test_nodes.py tests/test_integracao.py -v -s --record-mode=new_episodes +# validar replay deterministico +pytest tests/test_nodes.py tests/test_integracao.py -v -s --record-mode=none ``` -langgraph>=0.2.0 -langchain>=0.2.0 -langchain-core>=0.2.0 -langchain-google-genai>=2.0.0 -langchain-openai -python-dotenv>=1.0.0 -pytest>=9.0.2 -pytest-recording>=0.13.0 -pytest-timeout>=2.3.0 + +As cassetes ficam em `tests/cassettes/`. + +Teste opcional com API real (drift provider/modelo): + +```bash +pytest tests/test_real_api_smoke.py -v -s -m real_api ``` -## Stack +## CI hibrida + +Workflow em `.github/workflows/ci.yml`: + +- `tests-vcr`: job padrao em PR/push com execucao deterministica (`--record-mode=none`); +- `record-vcr-cassettes`: job manual em `workflow_dispatch` para gravar/atualizar cassetes e publicar artifact; +- `tests-real-api`: job opcional manual/noturno com `GOOGLE_API_KEY` real para detectar drift. + +## Validacao de distribuicao -- **LangGraph** para orquestração do grafo de agentes -- **Google Gemini** (gemini-2.5-flash, padrão atual) para chamadas LLM -- **OpenAI Chat Models** suportados via seletor de modelo -- **SQLite** como banco de dados (modo read-only) -- **pytest + VCR** para testes determinísticos com gravação de chamadas +```bash +python -m build + +python -m venv .venv-smoke +source .venv-smoke/bin/activate +pip install dist/*.whl + +python -c "from text_to_insight import InsightEngine, Graph; print('import_ok')" +``` diff --git a/main.py b/main.py index 1e9e899..9948f0e 100644 --- a/main.py +++ b/main.py @@ -1,184 +1,9 @@ #!/usr/bin/env python3 """ -Script principal para demonstração do grafo Text-to-Insight. +Adaptador fino para execução da CLI da biblioteca Text-to-Insight. """ -import argparse -import time -import os -from dotenv import load_dotenv -from langgraph.graph import StateGraph -from src.graph import Graph -from src.utils import salvar_metricas_csv - -load_dotenv() - -def executar_consulta(grafo: StateGraph, pergunta: str, hitl_ativado: bool = True) -> dict: - """Executa uma consulta através do grafo Text-to-Insight.""" - config = {"configurable": {"thread_id": "sessao_usuario_1"}} - estado_inicial = { - "pergunta_usuario": pergunta, - "contexto_schema": "", - "sql_gerada": "", - "saida_terminal": "", - "feedback_critico": "", - "erro_execucao": "", - "historico_conversa": [], - "status": "iniciado", - "tentativas_loop": 0, - "db_path": "data/olist_relational.db", - "espera_humana": False, - } - - print("=" * 70) - print("INICIANDO TEXT-TO-INSIGHT") - print("=" * 70) - print(f"\nPergunta: {pergunta}\n") - print("=" * 70) - - # Intervalo de cálculo da latência da consulta no grafo. Optei por considerar somente - # o tempo em que o grafo de fato está rodando, então não incluo o tempo que leva as linhas - # anteriores na main ou antes desse trecho. - lat_inicio = time.perf_counter() - - while True: - for evento in grafo.grafo_text_to_insight.stream(estado_inicial, config, stream_mode="values"): - pass - - snapshot = grafo.grafo_text_to_insight.get_state(config) - - if not snapshot.next: - resultado_final = snapshot.values - lat_fim = time.perf_counter() - latencia_consulta = lat_fim - lat_inicio - salvar_metricas_csv(resultado_final, latencia_consulta) - return resultado_final - - if "espera_humana" in snapshot.next: - pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") - historico_atual = snapshot.values.get("historico_conversa", []) - - if not hitl_ativado: - print("\n[HITL] Intervenção humana solicitada, mas o modo HITL está DESATIVADO.") - print("[HITL] Encerrando execução com status de bloqueio.") - - resultado_final = dict(snapshot.values) - resultado_final.update({ - "status": "bloqueado_hitl", - "erro_execucao": ( - "Fluxo bloqueado: o planejador solicitou intervenção humana, " - "mas o HITL está desativado (--hitl off)." - ), - "saida_terminal": "[HITL] Bloqueado: intervenção humana necessária com HITL off.", - }) - - lat_fim = time.perf_counter() - latencia_consulta = lat_fim - lat_inicio - salvar_metricas_csv(resultado_final, latencia_consulta) - return resultado_final - - print(f"\n[HITL]: {pergunta_agente}") - - resposta = input("[RESPOSTA USUARIO]: ") - historico_atual.append(("ai: "+pergunta_agente, "\nuser: "+resposta)) - - grafo.grafo_text_to_insight.update_state(config, {"historico_conversa": historico_atual, "espera_humana": False}) - - estado_inicial = None - - # Código morto porque o fluxo já retorna dentro do while acima - # resultado_final = grafo.invoke(estado_inicial) - # return resultado_final - - -def _parse_args() -> argparse.Namespace: - """Faz parse dos argumentos da CLI.""" - parser = argparse.ArgumentParser( - description="Executa o pipeline Text-to-Insight para responder perguntas sobre o banco SQLite." - ) - parser.add_argument( - "--hitl", - choices=["on", "off"], - default="on", - help="Ativa/desativa o modo Human-in-the-Loop. Padrão: on.", - ) - parser.add_argument( - "pergunta", - nargs="*", - help="Pergunta em linguagem natural. Se omitida, usa uma pergunta padrão.", - ) - return parser.parse_args() - - -def exibir_resultado(resultado: dict) -> None: - """Exibe o resultado final da execução de forma formatada.""" - print("\n" + "=" * 70) - print("EXECUCAO CONCLUIDA") - print("=" * 70) - - print(f"\nStatus Final: {resultado.get('status', 'desconhecido').upper()}") - print(f"Total de Tentativas: {resultado.get('tentativas_loop', 0)}") - - print("\n" + "-" * 70) - print("SQL GERADA:") - print("-" * 70) - sql = resultado.get("sql_gerada", "").strip() - print(sql if sql else "[Nenhuma SQL gerada]") - - print("\n" + "-" * 70) - print("SAIDA DA EXECUCAO:") - print("-" * 70) - saida = resultado.get("saida_terminal", "").strip() - print(saida if saida else "[Nenhuma saida]") - - print("\n" + "-" * 70) - print("RESULTADO (preview):") - print("-" * 70) - preview = resultado.get("linhas_resultado_preview", []) - total = resultado.get("total_linhas_resultado", 0) - if preview: - for row in preview[:10]: - print(row) - if total > 10: - print(f"... ({total - 10} linhas omitidas)") - else: - print("[Nenhum resultado]") - - print("\n" + "-" * 70) - print("FEEDBACK DO CRITICO:") - print("-" * 70) - feedback = resultado.get("feedback_critico", "").strip() - print(feedback if feedback else "[Nenhum feedback]") - - # Se o nó de resposta final gerou uma resposta em linguagem natural, exibi-la - resposta_natural = resultado.get("resposta_natural", "").strip() - print("\n" + "-" * 70) - print("RESPOSTA NATURAL AO USUARIO:") - print("-" * 70) - print(resposta_natural) - - print("\n" + "=" * 70 + "\n") - - -def main(): - args = _parse_args() - - if args.pergunta: - pergunta = " ".join(args.pergunta) - else: - pergunta = "Quantos pedidos existem no banco?" - print(f"Nenhuma pergunta fornecida. Usando exemplo: '{pergunta}'\n") - - hitl_ativado = args.hitl == "on" - print(f"[CONFIG] HITL: {'ATIVADO' if hitl_ativado else 'DESATIVADO'}") - - api_key = os.getenv("GOOGLE_API_KEY") #GOOGLE_API_KEY/OPENAI_API_KEY - model = "gemini-2.5-flash" #gemini-2.5-flash/gpt-5-nano - - grafo = Graph(api_key, model, hitl_ativado) - - resultado = executar_consulta(grafo, pergunta, hitl_ativado=hitl_ativado) - exibir_resultado(resultado) +from text_to_insight.cli import main if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index f546d1c..15a1d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,13 +28,20 @@ dependencies = [ "langgraph>=0.2.0", "langchain>=0.2.0", "langchain-core>=0.2.0", + "langchain-google-genai>=2.0.0", + "langchain-openai>=0.1.0", "python-dotenv>=1.0.0", ] +[project.scripts] +text-to-insight = "text_to_insight.cli:main" + [project.optional-dependencies] dev = [ "pytest>=7.0", "pytest-cov>=4.0", + "pytest-recording>=0.13.0", + "pytest-timeout>=2.3.0", "black>=23.0", "isort>=5.12", "flake8>=6.0", @@ -51,8 +58,10 @@ Documentation = "https://github.com/gruporaia/TextToInsight#readme" Repository = "https://github.com/gruporaia/TextToInsight.git" "Bug Tracker" = "https://github.com/gruporaia/TextToInsight/issues" -[tool.setuptools] -packages = ["src"] +[tool.setuptools.packages.find] +where = ["."] +include = ["text_to_insight*"] +exclude = ["tests*", "build*", "meus_testes*"] [tool.black] line-length = 88 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..13fd702 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + real_api: testes que executam chamadas reais ao provedor/modelo LLM diff --git a/requirements.txt b/requirements.txt index 30663a7..4052cae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ langchain>=0.2.0 langchain-core>=0.2.0 python-dotenv>=1.0.0 langchain-google-genai>=2.0.0 -langchain-openai +langchain-openai>=0.1.0 pytest>=9.0.2 pytest-recording>=0.13.0 pytest-timeout>=2.3.0 \ No newline at end of file diff --git a/src/InsightEngine.py b/src/InsightEngine.py deleted file mode 100644 index 0caa88a..0000000 --- a/src/InsightEngine.py +++ /dev/null @@ -1,179 +0,0 @@ -import time -import os -from dotenv import load_dotenv -from langgraph.graph import StateGraph -from src.graph import Graph -from src.utils import salvar_metricas_csv - -class InsightEngine: - def __init__(self, api_key, model, db_path, hitl=False, show_output=False): - self._hitl_ativado = hitl - self._show_output = show_output - print(f"[CONFIG] HITL: {'ATIVADO' if self._hitl_ativado else 'DESATIVADO'}") - print(f"[CONFIG] SHOW_OUTPUT: {'ATIVADO' if self._show_output else 'DESATIVADO'}") - - self._model = model #gemini-2.5-flash/gpt-5-nano - self._db_path = db_path #"data/olist_relational.db" - - self._grafo = Graph(api_key, self._model, self._hitl_ativado) - - def _exibir_resultado(self, resultado: dict) -> None: - """Exibe o resultado final da execução de forma formatada.""" - print("\n" + "=" * 70) - print("EXECUCAO CONCLUIDA") - print("=" * 70) - - print(f"\nStatus Final: {resultado.get('status', 'desconhecido').upper()}") - print(f"Total de Tentativas: {resultado.get('tentativas_loop', 0)}") - - print("\n" + "-" * 70) - print("SQL GERADA:") - print("-" * 70) - sql = resultado.get("sql_gerada", "").strip() - print(sql if sql else "[Nenhuma SQL gerada]") - - print("\n" + "-" * 70) - print("SAIDA DA EXECUCAO:") - print("-" * 70) - saida = resultado.get("saida_terminal", "").strip() - print(saida if saida else "[Nenhuma saida]") - - print("\n" + "-" * 70) - print("RESULTADO (preview):") - print("-" * 70) - preview = resultado.get("linhas_resultado_preview", []) - total = resultado.get("total_linhas_resultado", 0) - if preview: - for row in preview[:10]: - print(row) - if total > 10: - print(f"... ({total - 10} linhas omitidas)") - else: - print("[Nenhum resultado]") - - print("\n" + "-" * 70) - print("FEEDBACK DO CRITICO:") - print("-" * 70) - feedback = resultado.get("feedback_critico", "").strip() - print(feedback if feedback else "[Nenhum feedback]") - - # Se o nó de resposta final gerou uma resposta em linguagem natural, exibi-la - resposta_natural = resultado.get("resposta_natural", "").strip() - print("\n" + "-" * 70) - print("RESPOSTA NATURAL AO USUARIO:") - print("-" * 70) - print(resposta_natural) - - print("\n" + "=" * 70 + "\n") - - def get_insight(self, thread_id, query=None, user_response=None): - """Executa uma consulta através do grafo Text-to-Insight.""" - config = {"configurable": {"thread_id": thread_id}} - - snapshot = self._grafo.grafo_text_to_insight.get_state(config) - - if snapshot.next and user_response: - historico = snapshot.values.get("historico_conversa", []) - pergunta_ai = snapshot.values.get("pergunta_ao_usuario", "") - historico.append((f"ai: {pergunta_ai}", f"user: {user_response}")) - - self._grafo.grafo_text_to_insight.update_state( - config, {"historico_conversa": historico, "espera_humana": False} - ) - estado_execucao = None - else: - estado_execucao = { - "pergunta_usuario": query, - "contexto_schema": "", - "sql_gerada": "", - "saida_terminal": "", - "feedback_critico": "", - "erro_execucao": "", - "historico_conversa": [], - "status": "iniciado", - "tentativas_loop": 0, - "db_path": self._db_path, - "espera_humana": False, - } - - pergunta_exibicao = query if query else snapshot.values.get("pergunta_usuario", "Retomando conversa...") - - print("=" * 70) - print("INICIANDO TEXT-TO-INSIGHT") - print("=" * 70) - print(f"\nPergunta: {pergunta_exibicao}\n") - print("=" * 70) - - # Intervalo de cálculo da latência da consulta no grafo. Optei por considerar somente - # o tempo em que o grafo de fato está rodando, então não incluo o tempo que leva as linhas - # anteriores na main ou antes desse trecho. - lat_inicio = time.perf_counter() - - while True: - for evento in self._grafo.grafo_text_to_insight.stream(estado_execucao, config, stream_mode="values"): - pass - - snapshot = self._grafo.grafo_text_to_insight.get_state(config) - - if not snapshot.next: - resultado_final = snapshot.values - lat_fim = time.perf_counter() - latencia_consulta = lat_fim - lat_inicio - salvar_metricas_csv(resultado_final, latencia_consulta) - if self._show_output: - self._exibir_resultado(resultado_final) - return resultado_final - - if "espera_humana" in snapshot.next: - pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") - historico_atual = snapshot.values.get("historico_conversa", []) - - #discutir com na próxima reunião qual a necessidade de manter esse bloco/mudar a estratégia de toggle de remover completamente ṕara bloquear o fluxo - if not self._hitl_ativado: - print("\n[HITL] Intervenção humana solicitada, mas o modo HITL está DESATIVADO.") - print("[HITL] Encerrando execução com status de bloqueio.") - - resultado_final = dict(snapshot.values) - resultado_final.update({ - "status": "bloqueado_hitl", - "erro_execucao": ( - "Fluxo bloqueado: o planejador solicitou intervenção humana, " - "mas o HITL está desativado (--hitl off)." - ), - "saida_terminal": "[HITL] Bloqueado: intervenção humana necessária com HITL off.", - }) - - lat_fim = time.perf_counter() - latencia_consulta = lat_fim - lat_inicio - salvar_metricas_csv(resultado_final, latencia_consulta) - return resultado_final - - return { - "status": "AWAITING_USER", - "message": pergunta_agente, - "chat_history": historico_atual, - "thread_id": thread_id - } - - #Essa implementação anterior era mais simples, lembrar de avaliar complexidade de uso da lib - # print(f"\n[HITL]: {pergunta_agente}") - - # resposta = input("[RESPOSTA USUARIO]: ") - # historico_atual.append(("ai: "+pergunta_agente, "\nuser: "+resposta)) - - # self._grafo.grafo_text_to_insight.update_state(config, {"historico_conversa": historico_atual, "espera_humana": False}) - - # estado_inicial = None - -if __name__ == "__main__": - load_dotenv() - API_KEY = os.getenv("OPENAI_API_KEY") #GOOGLE_API_KEY/OPENAI_API_KEY - MODEL = "gpt-5-nano" #gemini-2.5-flash/gpt-5-nano - DB_PATH = "data/olist_relational.db" - engine = InsightEngine(API_KEY, MODEL, DB_PATH, hitl=True, show_output=True) - resultado = engine.get_insight("thread_1", "Liste os itens mais quentes do mercado") #Quantos pedidos existem no banco? - if resultado.get("status") == "AWAITING_USER": - print("\n[HITL] Aguardando resposta do usuário...") - print(f"Pergunta do agente: {resultado.get('message')}") - print(f"Histórico de conversa até agora: {resultado.get('chat_history')}") - engine.get_insight("thread_1", user_response="Sim, pode prosseguir, faça todas as assunções necessárias.") \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index 84b8aff..0000000 --- a/src/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Núcleo do projeto Text-to-Insight. - -Submódulos: - - state: Definição do estado compartilhado do grafo. - - nodes: Nós individuais do grafo (planner, schema, code_agent, sandbox, critic). - - routers: Funções de roteamento condicional entre nós. - - graph: Grafo compilado pronto para execução. -""" - -from .state import EstadoTextToInsight - -__all__ = ["EstadoTextToInsight"] diff --git a/tests/conftest.py b/tests/conftest.py index 26aebbc..65faed1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,72 @@ +import re + import pytest +REDACTED = "" + +# Cobrem query string (key=...), payload JSON e eventuais tokens em texto. +SENSITIVE_TEXT_PATTERNS = [ + re.compile(r"(?i)([?&](?:key|api[_-]?key|access_token|token)=)([^&#]+)"), + re.compile(r'(?i)("(?:key|api[_-]?key|access_token|token|authorization)"\s*:\s*")([^"]+)(")'), + re.compile(r"(?i)(authorization\s*:\s*bearer\s+)([A-Za-z0-9._-]+)"), +] + +SENSITIVE_HEADERS = { + "authorization", + "proxy-authorization", + "x-goog-api-key", + "x-api-key", + "api-key", +} + + +def _mask_sensitive_text(text: str) -> str: + """Aplica mascaramento de segredos em strings gravadas pelo VCR.""" + masked = text + for pattern in SENSITIVE_TEXT_PATTERNS: + if pattern.groups >= 3: + masked = pattern.sub(rf"\1{REDACTED}\3", masked) + else: + masked = pattern.sub(rf"\1{REDACTED}", masked) + return masked + + +def _sanitize_vcr_request(request): + """Censura dados sensíveis antes do request ser gravado em cassette.""" + # 1) Headers (lista de valores por header) + for header_name, values in list(request.headers.items()): + if header_name.lower() in SENSITIVE_HEADERS: + request.headers[header_name] = [REDACTED] + continue + request.headers[header_name] = [_mask_sensitive_text(v) for v in values] + + # 2) URL completa + if getattr(request, "uri", None): + request.uri = _mask_sensitive_text(request.uri) + + # 3) Body (bytes ou str) + body = getattr(request, "body", None) + if body: + if isinstance(body, bytes): + decoded = body.decode("utf-8", errors="ignore") + request.body = _mask_sensitive_text(decoded).encode("utf-8") + elif isinstance(body, str): + request.body = _mask_sensitive_text(body) + + return request + + @pytest.fixture(scope="module") def vcr_config(): """ - Configuração global do VCR para este módulo. - Garante que as chaves de API sejam censuradas e não vazem nos arquivos .yaml em tests/cassettes + Configuração global do VCR. + + Objetivo: impedir vazamento de chaves/tokens em `tests/cassettes/*.yaml`. """ return { - # Oculta a chave se ela for enviada no cabeçalho - "filter_headers": ["x-goog-api-key", "authorization"], - - # Oculta a chave se ela for enviada na URL - "filter_query_parameters": ["key"], + # Filtros nativos do VCR para casos comuns. + "filter_headers": sorted(SENSITIVE_HEADERS), + "filter_query_parameters": ["key", "api_key", "api-key", "access_token", "token"], + # Sanitização extra para payload JSON/URI em formatos não cobertos pelos filtros nativos. + "before_record_request": _sanitize_vcr_request, } \ No newline at end of file diff --git a/tests/test_componentes.py b/tests/test_componentes.py index 2845b1e..00301ce 100644 --- a/tests/test_componentes.py +++ b/tests/test_componentes.py @@ -20,7 +20,7 @@ def test_schema_extrai_tabelas(): """Schema node retorna contexto com tabelas do olist DB.""" - from src.nodes.schema import nos_nodo_esquema + from text_to_insight.nodes.schema import nos_nodo_esquema estado = {"db_path": DB_PATH, "pergunta_usuario": "teste"} resultado = nos_nodo_esquema(estado) @@ -32,7 +32,7 @@ def test_schema_extrai_tabelas(): def test_schema_erro_db_invalido(): """Schema node retorna erro quando db_path não existe.""" - from src.nodes.schema import nos_nodo_esquema + from text_to_insight.nodes.schema import nos_nodo_esquema estado = {"db_path": "/caminho/inexistente.db", "pergunta_usuario": "teste"} resultado = nos_nodo_esquema(estado) @@ -47,7 +47,7 @@ def test_schema_erro_db_invalido(): def test_sql_valida_select(): """Aceita SELECT válido.""" - from src.nodes.code_agent.code_sql import validar_sql_segura + from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura ok, msg = validar_sql_segura("SELECT COUNT(*) FROM orders") assert ok is True @@ -56,7 +56,7 @@ def test_sql_valida_select(): def test_sql_valida_cte(): """Aceita WITH/CTE.""" - from src.nodes.code_agent.code_sql import validar_sql_segura + from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura ok, msg = validar_sql_segura("WITH totals AS (SELECT id FROM orders) SELECT * FROM totals") assert ok is True @@ -64,7 +64,7 @@ def test_sql_valida_cte(): def test_sql_rejeita_insert(): """Rejeita INSERT.""" - from src.nodes.code_agent.code_sql import validar_sql_segura + from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura ok, msg = validar_sql_segura("INSERT INTO orders VALUES (1, 2, 3)") assert ok is False @@ -72,7 +72,7 @@ def test_sql_rejeita_insert(): def test_sql_rejeita_drop(): """Rejeita DROP.""" - from src.nodes.code_agent.code_sql import validar_sql_segura + from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura ok, msg = validar_sql_segura("DROP TABLE orders") assert ok is False @@ -80,7 +80,7 @@ def test_sql_rejeita_drop(): def test_sql_rejeita_multiplas(): """Rejeita múltiplas statements.""" - from src.nodes.code_agent.code_sql import validar_sql_segura + from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura ok, msg = validar_sql_segura("SELECT 1; SELECT 2") assert ok is False @@ -88,7 +88,7 @@ def test_sql_rejeita_multiplas(): def test_sql_rejeita_vazia(): """Rejeita SQL vazia.""" - from src.nodes.code_agent.code_sql import validar_sql_segura + from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura ok, msg = validar_sql_segura("") assert ok is False @@ -100,7 +100,7 @@ def test_sql_rejeita_vazia(): def test_execucao_sql_valida(): """Executa SELECT real no olist DB e retorna resultados.""" - from src.nodes.code_agent.code_sql import executar_sql_sqlite + from text_to_insight.nodes.code_agent.code_sql import executar_sql_sqlite resultado = executar_sql_sqlite(DB_PATH, "SELECT COUNT(*) as total FROM orders") assert resultado["ok"] is True @@ -110,7 +110,7 @@ def test_execucao_sql_valida(): def test_execucao_sql_com_limite(): """Respeita limite_preview.""" - from src.nodes.code_agent.code_sql import executar_sql_sqlite + from text_to_insight.nodes.code_agent.code_sql import executar_sql_sqlite resultado = executar_sql_sqlite(DB_PATH, "SELECT * FROM orders", limite_preview=5) assert resultado["ok"] is True @@ -119,7 +119,7 @@ def test_execucao_sql_com_limite(): def test_execucao_sql_invalida(): """Retorna erro para SQL com sintaxe inválida.""" - from src.nodes.code_agent.code_sql import executar_sql_sqlite + from text_to_insight.nodes.code_agent.code_sql import executar_sql_sqlite resultado = executar_sql_sqlite(DB_PATH, "SELECT FROM") assert resultado["ok"] is False @@ -128,7 +128,7 @@ def test_execucao_sql_invalida(): def test_execucao_db_inexistente(): """Retorna erro para DB inexistente.""" - from src.nodes.code_agent.code_sql import executar_sql_sqlite + from text_to_insight.nodes.code_agent.code_sql import executar_sql_sqlite resultado = executar_sql_sqlite("/nao/existe.db", "SELECT 1") assert resultado["ok"] is False @@ -140,7 +140,7 @@ def test_execucao_db_inexistente(): def test_executor_sucesso(): """Executor executa SQL do estado e retorna resultado.""" - from src.nodes.sandbox import nos_nodo_sandbox + from text_to_insight.nodes.sandbox import nos_nodo_sandbox estado = { "sql_gerada": "SELECT COUNT(*) as total FROM orders", @@ -156,7 +156,7 @@ def test_executor_sucesso(): def test_executor_sql_vazia(): """Executor retorna erro quando sql_gerada está vazia.""" - from src.nodes.sandbox import nos_nodo_sandbox + from text_to_insight.nodes.sandbox import nos_nodo_sandbox estado = { "sql_gerada": "", @@ -170,7 +170,7 @@ def test_executor_sql_vazia(): def test_executor_sql_com_erro(): """Executor retorna exec_erro para SQL com problema.""" - from src.nodes.sandbox import nos_nodo_sandbox + from text_to_insight.nodes.sandbox import nos_nodo_sandbox estado = { "sql_gerada": "SELECT * FROM tabela_que_nao_existe", @@ -189,7 +189,7 @@ def test_executor_sql_com_erro(): def test_roteador_sandbox_exec_ok(): """Roteador sandbox direciona para critico quando exec_ok.""" - from src.routers.edges import roteador_sandbox + from text_to_insight.routers.edges import roteador_sandbox estado = {"status": "exec_ok", "tentativas_loop": 1} assert roteador_sandbox(estado) == "critico" @@ -197,7 +197,7 @@ def test_roteador_sandbox_exec_ok(): def test_roteador_sandbox_exec_erro(): """Roteador sandbox direciona para planejador quando exec_erro.""" - from src.routers.edges import roteador_sandbox + from text_to_insight.routers.edges import roteador_sandbox estado = {"status": "exec_erro", "tentativas_loop": 1} assert roteador_sandbox(estado) == "planejador" @@ -205,7 +205,7 @@ def test_roteador_sandbox_exec_erro(): def test_roteador_sandbox_muitas_tentativas(): """Roteador sandbox direciona para planejador com muitas tentativas.""" - from src.routers.edges import roteador_sandbox + from text_to_insight.routers.edges import roteador_sandbox estado = {"status": "exec_erro", "tentativas_loop": 5} assert roteador_sandbox(estado) == "planejador" @@ -213,7 +213,7 @@ def test_roteador_sandbox_muitas_tentativas(): def test_roteador_planejador_sem_schema(): """Roteador planejador direciona para esquema quando schema vazio.""" - from src.routers.edges import roteador_planejador + from text_to_insight.routers.edges import roteador_planejador estado = {"contexto_schema": "", "status": "iniciado"} assert roteador_planejador(estado) == "esquema" @@ -221,7 +221,7 @@ def test_roteador_planejador_sem_schema(): def test_roteador_planejador_pronto(): """Roteador planejador direciona para agente_codigo quando pronto.""" - from src.routers.edges import roteador_planejador + from text_to_insight.routers.edges import roteador_planejador estado = {"contexto_schema": "tabelas...", "status": "pronto_codificacao"} assert roteador_planejador(estado) == "agente_codigo" @@ -229,14 +229,14 @@ def test_roteador_planejador_pronto(): def test_roteador_planejador_aprovado(): """Roteador planejador direciona para fim quando aprovado.""" - from src.routers.edges import roteador_planejador + from text_to_insight.routers.edges import roteador_planejador estado = {"contexto_schema": "tabelas...", "status": "aprovado"} assert roteador_planejador(estado) == "fim" def test_roteador_planejador_espera_humana(): """Roteador planejador direciona para espera_humana se a flag esperar_usuario for True.""" - from src.routers.edges import roteador_planejador + from text_to_insight.routers.edges import roteador_planejador estado = { "espera_humana": True, diff --git a/tests/test_integracao.py b/tests/test_integracao.py index 940e30e..850ddb9 100644 --- a/tests/test_integracao.py +++ b/tests/test_integracao.py @@ -12,8 +12,6 @@ import pytest from dotenv import load_dotenv -from src.nodes.schema import _formatar_schema_sqlite - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "olist_relational.db") @@ -28,8 +26,8 @@ def grafo(): if not api_key: pytest.skip("Variável GOOGLE_API_KEY não encontrada. Pulando testes de integração.") - from src.graph import Graph - return Graph(api_key, "gemini-2.5-flash") + from text_to_insight.graph import Graph + return Graph(api_key, "gemini-2.5-flash", hitl=True) @pytest.fixture(autouse=True) diff --git a/tests/test_main_engine_integracao.py b/tests/test_main_engine_integracao.py new file mode 100644 index 0000000..1084661 --- /dev/null +++ b/tests/test_main_engine_integracao.py @@ -0,0 +1,123 @@ +"""Integração entre adaptador CLI (main.py) e InsightEngine.""" + +import os +import sys +import importlib +from types import SimpleNamespace + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import main as main_entry +from text_to_insight.InsightEngine import InsightEngine + + +class _FakeCompiledGraph: + def __init__(self): + self._threads: dict[str, dict] = {} + + def _thread_state(self, thread_id: str) -> dict: + if thread_id not in self._threads: + self._threads[thread_id] = { + "values": {}, + "next": (), + } + return self._threads[thread_id] + + @staticmethod + def _estado_aprovado(base_state: dict) -> dict: + estado = dict(base_state) + estado.update( + { + "status": "aprovado", + "espera_humana": False, + "sql_gerada": "SELECT 1 AS total", + "saida_terminal": "[EXECUTOR] Execucao OK | linhas_total=1 | preview=1", + "linhas_resultado_preview": [{"total": 1}], + "total_linhas_resultado": 1, + "feedback_critico": "Resultado aprovado.", + "resposta_natural": "Existe 1 registro no resultado.", + } + ) + return estado + + def stream(self, estado_execucao, config, stream_mode="values"): + thread_id = config["configurable"]["thread_id"] + state = self._thread_state(thread_id) + + if estado_execucao is not None: + pergunta = str(estado_execucao.get("pergunta_usuario", "")) + if "hitl" in pergunta.lower(): + state["values"] = { + **estado_execucao, + "status": "aguardando_input", + "espera_humana": True, + "pergunta_ao_usuario": "Pode confirmar os filtros da consulta?", + } + state["next"] = ("espera_humana",) + else: + state["values"] = self._estado_aprovado(estado_execucao) + state["next"] = () + else: + state["values"] = self._estado_aprovado(state["values"]) + state["next"] = () + + yield state["values"] + + def get_state(self, config): + thread_id = config["configurable"]["thread_id"] + state = self._thread_state(thread_id) + return SimpleNamespace(values=state["values"], next=state["next"]) + + def update_state(self, config, values): + thread_id = config["configurable"]["thread_id"] + state = self._thread_state(thread_id) + state["values"].update(values) + + +class _FakeGraph: + def __init__(self, api_key, model, hitl=True): + self.grafo_text_to_insight = _FakeCompiledGraph() + + +def _patch_integracao(monkeypatch): + insight_engine_module = importlib.import_module("text_to_insight.InsightEngine") + monkeypatch.setattr(insight_engine_module, "Graph", _FakeGraph) + monkeypatch.setattr("text_to_insight.runtime.salvar_metricas_csv", lambda *args, **kwargs: None) + monkeypatch.setenv("GOOGLE_API_KEY", "fake-key") + + +def test_integracao_main_engine_sucesso(monkeypatch): + _patch_integracao(monkeypatch) + + resultado = main_entry.main(["--hitl", "on", "Quantos pedidos existem?"]) + + assert resultado["status"] == "aprovado" + assert resultado["sql_gerada"] == "SELECT 1 AS total" + + +def test_integracao_main_engine_hitl_on_com_retomada(monkeypatch): + _patch_integracao(monkeypatch) + + engine = InsightEngine( + api_key="fake-key", + model="gemini-2.5-flash", + db_path="data/olist_relational.db", + hitl=True, + show_output=False, + ) + + primeiro = engine.get_insight(thread_id="thread_hitl", query="consulta com hitl") + assert primeiro["status"] == "AWAITING_USER" + + segundo = engine.resume(thread_id="thread_hitl", user_response="Sim, pode prosseguir") + assert segundo["status"] == "aprovado" + assert len(segundo.get("historico_conversa", [])) == 1 + + +def test_integracao_main_engine_hitl_off_bloqueado(monkeypatch): + _patch_integracao(monkeypatch) + + resultado = main_entry.main(["--hitl", "off", "consulta com hitl"]) + + assert resultado["status"] == "bloqueado_hitl" + assert "intervenção humana" in resultado["erro_execucao"].lower() diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 43d6c85..12f09d9 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -15,8 +15,6 @@ from dotenv import load_dotenv from langchain_google_genai import ChatGoogleGenerativeAI -import pytest - load_dotenv() sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -38,7 +36,7 @@ def llm(): def _obter_schema_real() -> str: """Helper: extrai schema real do olist DB (sem API, só SQLite).""" - from src.nodes.schema import nos_nodo_esquema + from text_to_insight.nodes.schema import nos_nodo_esquema resultado = nos_nodo_esquema({"db_path": DB_PATH, "pergunta_usuario": "teste"}) return resultado["contexto_schema"] @@ -49,7 +47,7 @@ def _obter_schema_real() -> str: def test_planner_sem_schema(llm): """Planner sem schema → aguardando_schema (determinístico, sem API).""" - from src.nodes.planner import nos_nodo_planejador + from text_to_insight.nodes.planner import nos_nodo_planejador estado = { "pergunta_usuario": "Quantos pedidos existem?", @@ -59,7 +57,7 @@ def test_planner_sem_schema(llm): "tentativas_loop": 0, "erro_execucao": "", } - resultado = nos_nodo_planejador(estado, llm) + resultado = nos_nodo_planejador(estado, llm, hitl=True) assert resultado["status"] == "aguardando_schema" @@ -71,7 +69,7 @@ def test_planner_sem_schema(llm): @pytest.mark.timeout(60) def test_planner_com_schema_decide_codificar(llm): """Planner com schema e sem feedback → deve decidir gerar código.""" - from src.nodes.planner import nos_nodo_planejador + from text_to_insight.nodes.planner import nos_nodo_planejador schema = _obter_schema_real() estado = { @@ -82,7 +80,7 @@ def test_planner_com_schema_decide_codificar(llm): "tentativas_loop": 0, "erro_execucao": "", } - resultado = nos_nodo_planejador(estado, llm) + resultado = nos_nodo_planejador(estado, llm, hitl=True) assert resultado["status"] in ("pronto_codificacao", "revisando_estrategia") print(f" → Planner decidiu: {resultado['status']}") @@ -92,7 +90,7 @@ def test_planner_com_schema_decide_codificar(llm): @pytest.mark.timeout(60) def test_planner_com_feedback_revisa(llm): """Planner com feedback do crítico → deve revisar estratégia.""" - from src.nodes.planner import nos_nodo_planejador + from text_to_insight.nodes.planner import nos_nodo_planejador time.sleep(5) # rate limit schema = _obter_schema_real() @@ -104,7 +102,7 @@ def test_planner_com_feedback_revisa(llm): "tentativas_loop": 1, "erro_execucao": "", } - resultado = nos_nodo_planejador(estado, llm) + resultado = nos_nodo_planejador(estado, llm, hitl=True) assert resultado["status"] in ("pronto_codificacao", "revisando_estrategia", "aguardando_input") print(f" → Planner decidiu: {resultado['status']}") @@ -113,7 +111,7 @@ def test_planner_com_feedback_revisa(llm): @pytest.mark.timeout(60) def test_planner_pergunta_fora_de_escopo(llm): """Planner deve detectar pergunta fora de escopo e levantar a flag de HITL.""" - from src.nodes.planner import nos_nodo_planejador + from text_to_insight.nodes.planner import nos_nodo_planejador time.sleep(5) # rate limit schema = _obter_schema_real() @@ -126,7 +124,7 @@ def test_planner_pergunta_fora_de_escopo(llm): "erro_execucao": "", } - resultado = nos_nodo_planejador(estado, llm) + resultado = nos_nodo_planejador(estado, llm, hitl=True) assert resultado.get("espera_humana") is True assert resultado.get("status") == "aguardando_input" @@ -142,7 +140,7 @@ def test_planner_pergunta_fora_de_escopo(llm): @pytest.mark.timeout(60) def test_code_agent_gera_sql(llm): """Code Agent recebe pergunta + schema → retorna SQL válida.""" - from src.nodes.code_agent.code_agent import nos_nodo_agente_codigo + from text_to_insight.nodes.code_agent.code_agent import nos_nodo_agente_codigo time.sleep(5) # rate limit schema = _obter_schema_real() @@ -170,7 +168,7 @@ def test_code_agent_gera_sql(llm): @pytest.mark.timeout(60) def test_code_agent_com_feedback_regenera(llm): """Code Agent com feedback do crítico → gera SQL diferente.""" - from src.nodes.code_agent.code_agent import nos_nodo_agente_codigo + from text_to_insight.nodes.code_agent.code_agent import nos_nodo_agente_codigo time.sleep(5) schema = _obter_schema_real() @@ -194,7 +192,7 @@ def test_code_agent_com_feedback_regenera(llm): def test_executor_com_sql_real(): """Executor executa SQL gerada manualmente contra olist DB.""" - from src.nodes.sandbox import nos_nodo_sandbox + from text_to_insight.nodes.sandbox import nos_nodo_sandbox estado = { "sql_gerada": "SELECT COUNT(*) as total_pedidos FROM orders", @@ -217,7 +215,7 @@ def test_executor_com_sql_real(): @pytest.mark.timeout(60) def test_critic_avalia_resultado_correto(llm): """Critic recebe pergunta + SQL + resultado OK → avalia com LLM.""" - from src.nodes.critic import nos_nodo_critico + from text_to_insight.nodes.critic import nos_nodo_critico time.sleep(5) # rate limit estado = { @@ -239,7 +237,7 @@ def test_critic_avalia_resultado_correto(llm): def test_critic_reprova_erro_execucao(llm): """Critic com erro de execução → reprova sem chamar API (determinístico).""" - from src.nodes.critic import nos_nodo_critico + from text_to_insight.nodes.critic import nos_nodo_critico estado = { "pergunta_usuario": "Quantos pedidos existem?", @@ -265,8 +263,8 @@ def test_critic_reprova_erro_execucao(llm): @pytest.mark.timeout(90) def test_cadeia_code_agent_executor(llm): """Code Agent gera SQL, Executor executa — testa a conexão entre os dois.""" - from src.nodes.code_agent.code_agent import nos_nodo_agente_codigo - from src.nodes.sandbox import nos_nodo_sandbox + from text_to_insight.nodes.code_agent.code_agent import nos_nodo_agente_codigo + from text_to_insight.nodes.sandbox import nos_nodo_sandbox time.sleep(5) # rate limit schema = _obter_schema_real() diff --git a/tests/test_real_api_smoke.py b/tests/test_real_api_smoke.py new file mode 100644 index 0000000..2012c63 --- /dev/null +++ b/tests/test_real_api_smoke.py @@ -0,0 +1,21 @@ +"""Smoke test opcional com API real para detectar drift de provider/modelo.""" + +import os +import pytest +from text_to_insight.model_selection import get_model +from dotenv import load_dotenv +load_dotenv() + +@pytest.mark.real_api +@pytest.mark.timeout(60) +def test_provider_model_smoke_real_api(): + api_key = os.getenv("GOOGLE_API_KEY") + if not api_key: + pytest.skip("GOOGLE_API_KEY não encontrada para teste real_api.") + + llm = get_model("gemini-2.5-flash", api_key) + resposta = llm.invoke("Responda apenas com: OK") + conteudo = str(getattr(resposta, "content", "")).strip().upper() + + assert conteudo != "" + assert "OK" in conteudo diff --git a/text_to_insight/InsightEngine.py b/text_to_insight/InsightEngine.py new file mode 100644 index 0000000..5ae11db --- /dev/null +++ b/text_to_insight/InsightEngine.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from typing import Any, Callable + +from .graph import Graph +from .runtime import construir_estado_inicial, executar_fluxo, exibir_resultado_console, registrar_resposta_humana +from .utils import salvar_metricas_csv + + +class InsightEngine: + """ + Camada de alto nível para executar o fluxo Text-to-Insight. + + Em termos simples: + - `run(...)` inicia uma nova consulta. + - `resume(...)` continua uma consulta que ficou pausada em HITL. + """ + + def __init__(self, api_key: str, model: str, db_path: str, hitl: bool = False, show_output: bool = False): + self._hitl_ativado = hitl + # `show_output` controla se a engine imprime o resultado final no terminal. + # Em cenários com CLI, normalmente deixamos False para evitar saída duplicada. + self._show_output = show_output + self._model = model + self._db_path = db_path + # O grafo compila os nós/roteadores e guarda memória por thread_id. + self._grafo = Graph(api_key=api_key, model=self._model, hitl=self._hitl_ativado) + + print(f"[CONFIG] HITL: {'ATIVADO' if self._hitl_ativado else 'DESATIVADO'}") + print(f"[CONFIG] SHOW_OUTPUT: {'ATIVADO' if self._show_output else 'DESATIVADO'}") + + def _config(self, thread_id: str) -> dict[str, Any]: + # O LangGraph usa esse bloco "configurable" para identificar a conversa. + return {"configurable": {"thread_id": thread_id}} + + def _exibir_inicio(self, pergunta: str) -> None: + # Banner simples para facilitar leitura no terminal. + print("=" * 70) + print("INICIANDO TEXT-TO-INSIGHT") + print("=" * 70) + print(f"\nPergunta: {pergunta}\n") + print("=" * 70) + + def run( + self, + thread_id: str, + query: str, + on_human_prompt: Callable[[str], str] | None = None, + ) -> dict[str, Any]: + """Inicia uma consulta nova dentro de uma thread (sessão).""" + # on_human_prompt = "função de callback" para HITL. + # Ela vem de fora da engine (quem chama o método) e recebe a + # pergunta do agente, retornando a resposta do usuário em texto. + return self.get_insight(thread_id=thread_id, query=query, on_human_prompt=on_human_prompt) + + def resume( + self, + thread_id: str, + user_response: str, + on_human_prompt: Callable[[str], str] | None = None, + ) -> dict[str, Any]: + """Retoma uma thread pausada em HITL usando a resposta do usuário.""" + return self.get_insight( + thread_id=thread_id, + user_response=user_response, + on_human_prompt=on_human_prompt, + ) + + def get_insight( + self, + thread_id: str, + query: str | None = None, + user_response: str | None = None, + on_human_prompt: Callable[[str], str] | None = None, + ) -> dict[str, Any]: + """ + Executa ou retoma uma consulta via thread_id. + + Contrato estável: + - Nova execução: informar `query`. + - Retomada HITL: informar `user_response` na mesma `thread_id`. + - Se houver pausa HITL e não houver callback/resposta, retorna status `AWAITING_USER`. + + Sobre `on_human_prompt`: + - Não é definido dentro desta classe. + - É passado por quem chama a engine. + - Exemplo real neste projeto: `text_to_insight/cli.py` usa + `_coletar_resposta_humana` e envia essa função para `run(...)`. + """ + config = self._config(thread_id) + app = self._grafo.grafo_text_to_insight + # `snapshot` é uma "foto" do estado atual salvo no checkpointer. + snapshot = app.get_state(config) + + # Caso 1: a thread estava pausada e o usuário acabou de enviar resposta. + 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...") + # Caso 2: chamada nova (primeira execução para essa pergunta). + elif query: + estado_execucao = construir_estado_inicial(query, self._db_path) + pergunta_exibicao = query + # Caso 3: a thread já está pausada, mas ainda sem resposta do usuário. + elif snapshot.next: + if not self._hitl_ativado: + # Com HITL desligado, não podemos pedir input humano: bloqueia o fluxo. + resultado_bloqueio = dict(snapshot.values) + resultado_bloqueio.update( + { + "status": "bloqueado_hitl", + "erro_execucao": ( + "Fluxo bloqueado: o planejador solicitou intervenção humana, " + "mas o HITL está desativado (--hitl off)." + ), + "saida_terminal": "[HITL] Bloqueado: intervenção humana necessária com HITL off.", + } + ) + salvar_metricas_csv(resultado_bloqueio, 0.0) + if self._show_output: + exibir_resultado_console(resultado_bloqueio) + return resultado_bloqueio + + # Com HITL ligado, devolvemos um payload simples para o cliente/CLI + # decidir como coletar a resposta humana. + return { + "status": "AWAITING_USER", + "message": snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?"), + "chat_history": snapshot.values.get("historico_conversa", []), + "thread_id": thread_id, + } + else: + raise ValueError("Informe `query` para nova execução ou `user_response` para retomada HITL.") + + # Só mostramos o banner quando a execução realmente vai rodar agora. + self._exibir_inicio(str(pergunta_exibicao)) + + # Loop principal de execução do grafo (até finalizar ou pausar em HITL). + resultado = executar_fluxo( + grafo_app=app, + config=config, + estado_execucao=estado_execucao, + hitl_ativado=self._hitl_ativado, + thread_id=thread_id, + # Aqui a engine repassa o callback para o runtime. + # Se vier None, o runtime não tenta ler input direto e + # devolve status AWAITING_USER para o chamador tratar. + on_human_prompt=on_human_prompt, + ) + + # Exibe saída final se configurado e se não ficou pendente de resposta humana. + if self._show_output and resultado.get("status") != "AWAITING_USER": + exibir_resultado_console(resultado) + + return resultado \ No newline at end of file diff --git a/text_to_insight/__init__.py b/text_to_insight/__init__.py new file mode 100644 index 0000000..0aee47b --- /dev/null +++ b/text_to_insight/__init__.py @@ -0,0 +1,7 @@ +"""API pública do pacote Text-to-Insight.""" + +from .InsightEngine import InsightEngine +from .graph import Graph +from .state import EstadoTextToInsight + +__all__ = ["InsightEngine", "Graph", "EstadoTextToInsight"] diff --git a/text_to_insight/cli.py b/text_to_insight/cli.py new file mode 100644 index 0000000..2b47c0a --- /dev/null +++ b/text_to_insight/cli.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""CLI do pacote Text-to-Insight.""" + +from __future__ import annotations + +import argparse +import os +from typing import Any + +from dotenv import load_dotenv + +from .InsightEngine import InsightEngine +from .runtime import exibir_resultado_console + +load_dotenv() + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Executa o pipeline Text-to-Insight para responder perguntas sobre o banco SQLite." + ) + parser.add_argument( + "--hitl", + choices=["on", "off"], + default="on", + help="Ativa/desativa o modo Human-in-the-Loop. Padrão: on.", + ) + parser.add_argument( + "--thread-id", + default="sessao_usuario_1", + help="Identificador da thread para execução e retomada.", + ) + parser.add_argument( + "--db-path", + default="data/olist_relational.db", + help="Caminho para o banco SQLite.", + ) + parser.add_argument( + "--model", + default="gemini-2.5-flash", + help="Modelo LLM a utilizar (ex: gemini-2.5-flash, gpt-5-nano).", + ) + parser.add_argument( + "--api-key-env", + default="GOOGLE_API_KEY", + help="Nome da variável de ambiente com a chave de API.", + ) + parser.add_argument( + "pergunta", + nargs="*", + help="Pergunta em linguagem natural. Se omitida, usa uma pergunta padrão.", + ) + return parser.parse_args(argv) + + +def _coletar_resposta_humana(pergunta_agente: str) -> str: + print(f"\n[HITL]: {pergunta_agente}") + return input("[RESPOSTA USUARIO]: ") + + +def main(argv: list[str] | None = None) -> dict[str, Any]: + args = _parse_args(argv) + + if args.pergunta: + pergunta = " ".join(args.pergunta) + else: + pergunta = "Quantos pedidos existem no banco?" + print(f"Nenhuma pergunta fornecida. Usando exemplo: '{pergunta}'\n") + + hitl_ativado = args.hitl == "on" + print(f"[CONFIG] HITL: {'ATIVADO' if hitl_ativado else 'DESATIVADO'}") + + api_key = os.getenv(args.api_key_env) + if not api_key: + raise RuntimeError( + f"Variável de ambiente '{args.api_key_env}' não encontrada. " + "Configure a chave da API antes de executar." + ) + + engine = InsightEngine( + api_key=api_key, + model=args.model, + db_path=args.db_path, + hitl=hitl_ativado, + show_output=False, + ) + + # show_output=False para evitar prints duplicados no console, já que exibir_resultado_console é chamado manualmente. + + 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. + while resultado.get("status") == "AWAITING_USER": + resposta = _coletar_resposta_humana(resultado.get("message", "Pode confirmar o prosseguimento?")) + resultado = engine.resume( + thread_id=args.thread_id, + user_response=resposta, + on_human_prompt=callback, + ) + + exibir_resultado_console(resultado) + return resultado + + +if __name__ == "__main__": + main() diff --git a/src/graph.py b/text_to_insight/graph.py similarity index 94% rename from src/graph.py rename to text_to_insight/graph.py index 7a6065a..43e4552 100644 --- a/src/graph.py +++ b/text_to_insight/graph.py @@ -32,13 +32,12 @@ def nos_nodo_espera_humana(estado: EstadoTextToInsight): return estado class Graph: - def __init__(self, api_key, model, hitl): - + def __init__(self, api_key: str, model: str, hitl: bool = True): self.llm = get_model(model, api_key) self.memory = MemorySaver() self.grafo_text_to_insight = self._compilar_grafo(hitl) - def _construir_grafo_text_to_insight(self, hitl) -> StateGraph: + def _construir_grafo_text_to_insight(self, hitl: bool) -> StateGraph: """ Constrói e compila o grafo de agentes Text-to-Insight. """ @@ -103,7 +102,7 @@ def roteador_critico(estado: EstadoTextToInsight) -> str: return construtor_grafo - def _compilar_grafo(self, hitl) -> "CompiledStateGraph": + 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"]) diff --git a/src/model_selection.py b/text_to_insight/model_selection.py similarity index 100% rename from src/model_selection.py rename to text_to_insight/model_selection.py diff --git a/src/nodes/__init__.py b/text_to_insight/nodes/__init__.py similarity index 96% rename from src/nodes/__init__.py rename to text_to_insight/nodes/__init__.py index 6591274..d099077 100644 --- a/src/nodes/__init__.py +++ b/text_to_insight/nodes/__init__.py @@ -1,29 +1,29 @@ -""" -Nós do grafo de agentes Text-to-Insight. - -Este módulo contém todas as funções que representam os nós do grafo, -cada uma responsável por uma etapa específica do pipeline. - -Nós disponíveis: - - planner: Orquestra a estratégia de execução. - - schema: Busca contexto e metadados do banco de dados. - - code_agent: Gera código Python baseado no plano. - - sandbox: Executa o código de forma segura. - - critic: Avalia a saída e fornece feedback. -""" - -from .planner import nos_nodo_planejador -from .schema import nos_nodo_esquema -from .code_agent.code_agent import nos_nodo_agente_codigo -from .sandbox import nos_nodo_sandbox -from .critic import nos_nodo_critico -from .response import nos_nodo_resposta - -__all__ = [ - "nos_nodo_planejador", - "nos_nodo_esquema", - "nos_nodo_agente_codigo", - "nos_nodo_sandbox", - "nos_nodo_critico", - "nos_nodo_resposta", -] +""" +Nós do grafo de agentes Text-to-Insight. + +Este módulo contém todas as funções que representam os nós do grafo, +cada uma responsável por uma etapa específica do pipeline. + +Nós disponíveis: + - planner: Orquestra a estratégia de execução. + - schema: Busca contexto e metadados do banco de dados. + - code_agent: Gera código Python baseado no plano. + - sandbox: Executa o código de forma segura. + - critic: Avalia a saída e fornece feedback. +""" + +from .planner import nos_nodo_planejador +from .schema import nos_nodo_esquema +from .code_agent.code_agent import nos_nodo_agente_codigo +from .sandbox import nos_nodo_sandbox +from .critic import nos_nodo_critico +from .response import nos_nodo_resposta + +__all__ = [ + "nos_nodo_planejador", + "nos_nodo_esquema", + "nos_nodo_agente_codigo", + "nos_nodo_sandbox", + "nos_nodo_critico", + "nos_nodo_resposta", +] diff --git a/src/nodes/code_agent/__init__.py b/text_to_insight/nodes/code_agent/__init__.py similarity index 100% rename from src/nodes/code_agent/__init__.py rename to text_to_insight/nodes/code_agent/__init__.py diff --git a/src/nodes/code_agent/code_agent.py b/text_to_insight/nodes/code_agent/code_agent.py similarity index 100% rename from src/nodes/code_agent/code_agent.py rename to text_to_insight/nodes/code_agent/code_agent.py diff --git a/src/nodes/code_agent/code_sql.py b/text_to_insight/nodes/code_agent/code_sql.py similarity index 100% rename from src/nodes/code_agent/code_sql.py rename to text_to_insight/nodes/code_agent/code_sql.py diff --git a/src/nodes/critic.py b/text_to_insight/nodes/critic.py similarity index 100% rename from src/nodes/critic.py rename to text_to_insight/nodes/critic.py diff --git a/src/nodes/planner.py b/text_to_insight/nodes/planner.py similarity index 100% rename from src/nodes/planner.py rename to text_to_insight/nodes/planner.py diff --git a/src/nodes/response.py b/text_to_insight/nodes/response.py similarity index 100% rename from src/nodes/response.py rename to text_to_insight/nodes/response.py diff --git a/src/nodes/sandbox.py b/text_to_insight/nodes/sandbox.py similarity index 100% rename from src/nodes/sandbox.py rename to text_to_insight/nodes/sandbox.py diff --git a/src/nodes/schema.py b/text_to_insight/nodes/schema.py similarity index 100% rename from src/nodes/schema.py rename to text_to_insight/nodes/schema.py diff --git a/src/routers/__init__.py b/text_to_insight/routers/__init__.py similarity index 100% rename from src/routers/__init__.py rename to text_to_insight/routers/__init__.py diff --git a/src/routers/edges.py b/text_to_insight/routers/edges.py similarity index 100% rename from src/routers/edges.py rename to text_to_insight/routers/edges.py diff --git a/text_to_insight/runtime.py b/text_to_insight/runtime.py new file mode 100644 index 0000000..c1a7114 --- /dev/null +++ b/text_to_insight/runtime.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import time +from typing import Any, Callable + +from .utils import salvar_metricas_csv + +HITL_AWAITING_STATUS = "AWAITING_USER" +HITL_BLOCKED_STATUS = "bloqueado_hitl" + + +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, + "contexto_schema": "", + "sql_gerada": "", + "saida_terminal": "", + "feedback_critico": "", + "erro_execucao": "", + "historico_conversa": [], + "status": "iniciado", + "tentativas_loop": 0, + "db_path": db_path, + "espera_humana": False, + } + + +def exibir_resultado_console(resultado: dict[str, Any]) -> None: + """Exibe o resultado final de forma consistente entre CLI e engine.""" + print("\n" + "=" * 70) + print("EXECUCAO CONCLUIDA") + print("=" * 70) + + print(f"\nStatus Final: {resultado.get('status', 'desconhecido').upper()}") + print(f"Total de Tentativas: {resultado.get('tentativas_loop', 0)}") + + print("\n" + "-" * 70) + print("SQL GERADA:") + print("-" * 70) + sql = str(resultado.get("sql_gerada", "")).strip() + print(sql if sql else "[Nenhuma SQL gerada]") + + print("\n" + "-" * 70) + print("SAIDA DA EXECUCAO:") + print("-" * 70) + saida = str(resultado.get("saida_terminal", "")).strip() + print(saida if saida else "[Nenhuma saida]") + + print("\n" + "-" * 70) + print("RESULTADO (preview):") + print("-" * 70) + preview = resultado.get("linhas_resultado_preview", []) or [] + total = int(resultado.get("total_linhas_resultado", 0) or 0) + if preview: + for row in preview[:10]: + print(row) + if total > 10: + print(f"... ({total - 10} linhas omitidas)") + else: + print("[Nenhum resultado]") + + print("\n" + "-" * 70) + print("FEEDBACK DO CRITICO:") + print("-" * 70) + feedback = str(resultado.get("feedback_critico", "")).strip() + print(feedback if feedback else "[Nenhum feedback]") + + print("\n" + "-" * 70) + print("RESPOSTA NATURAL AO USUARIO:") + print("-" * 70) + resposta_natural = str(resultado.get("resposta_natural", "")).strip() + print(resposta_natural if resposta_natural else "[Nenhuma resposta natural]") + + print("\n" + "=" * 70 + "\n") + + +def _resultado_aguardando_usuario(snapshot_values: dict[str, Any], thread_id: str) -> dict[str, Any]: + return { + "status": HITL_AWAITING_STATUS, + "message": snapshot_values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?"), + "chat_history": snapshot_values.get("historico_conversa", []), + "thread_id": thread_id, + } + + +def _resultado_hitl_bloqueado(snapshot_values: dict[str, Any]) -> dict[str, Any]: + resultado_final = dict(snapshot_values) + resultado_final.update( + { + "status": HITL_BLOCKED_STATUS, + "erro_execucao": ( + "Fluxo bloqueado: o planejador solicitou intervenção humana, " + "mas o HITL está desativado (--hitl off)." + ), + "saida_terminal": "[HITL] Bloqueado: intervenção humana necessária com HITL off.", + } + ) + return resultado_final + + +def registrar_resposta_humana(grafo_app: Any, config: dict[str, Any], user_response: str) -> None: + """Atualiza o estado da thread com a resposta humana para retomar o fluxo.""" + snapshot = grafo_app.get_state(config) + 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}) + + +def executar_fluxo( + grafo_app: Any, + config: dict[str, Any], + estado_execucao: dict[str, Any] | None, + hitl_ativado: bool, + thread_id: str, + on_human_prompt: Callable[[str], str] | None = None, +) -> dict[str, Any]: + """Executa o loop de runtime do grafo até finalizar ou aguardar input humano.""" + lat_inicio = time.perf_counter() + + while True: + for _ in grafo_app.stream(estado_execucao, config, stream_mode="values"): + pass + + snapshot = grafo_app.get_state(config) + + if not snapshot.next: + resultado_final = snapshot.values + salvar_metricas_csv(resultado_final, time.perf_counter() - lat_inicio) + return resultado_final + + if "espera_humana" in snapshot.next: + if not hitl_ativado: + print("\n[HITL] Intervenção humana solicitada, mas o modo HITL está DESATIVADO.") + print("[HITL] Encerrando execução com status de bloqueio.") + resultado_final = _resultado_hitl_bloqueado(snapshot.values) + salvar_metricas_csv(resultado_final, time.perf_counter() - lat_inicio) + return resultado_final + + pergunta_agente = snapshot.values.get("pergunta_ao_usuario", "Pode confirmar o prosseguimento?") + if on_human_prompt is None: + return _resultado_aguardando_usuario(snapshot.values, thread_id) + + user_response = on_human_prompt(pergunta_agente) + if user_response is None: + return _resultado_aguardando_usuario(snapshot.values, thread_id) + + registrar_resposta_humana(grafo_app, config, str(user_response)) + estado_execucao = None diff --git a/src/state.py b/text_to_insight/state.py similarity index 100% rename from src/state.py rename to text_to_insight/state.py diff --git a/src/utils.py b/text_to_insight/utils.py similarity index 100% rename from src/utils.py rename to text_to_insight/utils.py From ec96dc063f908fe9e0019e5a2ef4aa7474cc6248 Mon Sep 17 00:00:00 2001 From: calixto Date: Fri, 24 Apr 2026 12:50:09 -0300 Subject: [PATCH 9/9] =?UTF-8?q?testes=20de=20API=20conclu=C3=ADdos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_biblioteca_integracao.py | 261 ++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 tests/test_biblioteca_integracao.py diff --git a/tests/test_biblioteca_integracao.py b/tests/test_biblioteca_integracao.py new file mode 100644 index 0000000..645150b --- /dev/null +++ b/tests/test_biblioteca_integracao.py @@ -0,0 +1,261 @@ +"""Testes de integridade do Text-to-Insight como biblioteca. + +Responsabilidade deste arquivo +------------------------------ +Verificar o contrato público do pacote quando consumido como biblioteca +(`from text_to_insight import ...`), sem passar pela CLI ou pelo `main.py`. +Complementa `test_main_engine_integracao.py`, que exercita o caminho +CLI + engine. + +Aqui o foco é o que um desenvolvedor externo enxerga ao integrar a +biblioteca: quais símbolos estão expostos, como `InsightEngine.run`, +`resume` e o callback `on_human_prompt` se comportam, e qual é o formato +documentado do payload `AWAITING_USER`. + +Estratégia +---------- +Substituímos o `Graph` real por um fake determinístico que dispara HITL +quando a pergunta contém "hitl" (mesma convenção do arquivo vizinho). +Nenhuma chamada de API é feita. +""" + +from __future__ import annotations + +import importlib +from types import SimpleNamespace + +import pytest + +import text_to_insight +from text_to_insight import InsightEngine + + +# --------------------------------------------------------------------------- # +# Fake graph — reproduz o padrão de `test_main_engine_integracao.py` para +# manter a convenção de "hitl na pergunta → pausa". Mantido self-contained +# para não acoplar dois test files por um helper compartilhado. +# --------------------------------------------------------------------------- # + +class _FakeCompiledGraph: + def __init__(self): + self._threads: dict[str, dict] = {} + + def _thread_state(self, thread_id: str) -> dict: + if thread_id not in self._threads: + self._threads[thread_id] = {"values": {}, "next": ()} + return self._threads[thread_id] + + @staticmethod + def _estado_aprovado(base_state: dict) -> dict: + estado = dict(base_state) + estado.update( + { + "status": "aprovado", + "espera_humana": False, + "sql_gerada": "SELECT 1 AS total", + "saida_terminal": "[EXECUTOR] Execucao OK | linhas_total=1 | preview=1", + "linhas_resultado_preview": [{"total": 1}], + "total_linhas_resultado": 1, + "feedback_critico": "Resultado aprovado.", + "resposta_natural": "Existe 1 registro no resultado.", + } + ) + return estado + + def stream(self, estado_execucao, config, stream_mode="values"): + thread_id = config["configurable"]["thread_id"] + state = self._thread_state(thread_id) + + if estado_execucao is not None: + pergunta = str(estado_execucao.get("pergunta_usuario", "")) + if "hitl" in pergunta.lower(): + state["values"] = { + **estado_execucao, + "status": "aguardando_input", + "espera_humana": True, + "pergunta_ao_usuario": "Pode confirmar os filtros da consulta?", + } + state["next"] = ("espera_humana",) + else: + state["values"] = self._estado_aprovado(estado_execucao) + state["next"] = () + else: + state["values"] = self._estado_aprovado(state["values"]) + state["next"] = () + + yield state["values"] + + def get_state(self, config): + thread_id = config["configurable"]["thread_id"] + state = self._thread_state(thread_id) + return SimpleNamespace(values=state["values"], next=state["next"]) + + def update_state(self, config, values): + thread_id = config["configurable"]["thread_id"] + state = self._thread_state(thread_id) + state["values"].update(values) + + +class _FakeGraph: + def __init__(self, api_key, model, hitl=True): + self.grafo_text_to_insight = _FakeCompiledGraph() + + +@pytest.fixture +def patched_runtime(monkeypatch): + """Substitui o Graph real pelo fake e silencia o CSV de métricas.""" + insight_engine_module = importlib.import_module("text_to_insight.InsightEngine") + monkeypatch.setattr(insight_engine_module, "Graph", _FakeGraph) + monkeypatch.setattr( + "text_to_insight.runtime.salvar_metricas_csv", + lambda *args, **kwargs: None, + ) + monkeypatch.setenv("GOOGLE_API_KEY", "fake-key") + + +def _fabricar_engine(hitl: bool = False) -> InsightEngine: + return InsightEngine( + api_key="fake-key", + model="gemini-2.5-flash", + db_path="data/olist_relational.db", + hitl=hitl, + show_output=False, + ) + + +# --------------------------------------------------------------------------- # +# Contrato de importação — a API pública documentada em __init__.py +# --------------------------------------------------------------------------- # + +def test_simbolos_publicos_expostos_no_topo_do_pacote(): + """`text_to_insight` re-exporta os símbolos anunciados em `__all__`.""" + from text_to_insight import EstadoTextToInsight, Graph, InsightEngine as IE + + assert IE is InsightEngine + assert set(text_to_insight.__all__) == {"InsightEngine", "Graph", "EstadoTextToInsight"} + assert text_to_insight.Graph is Graph + assert text_to_insight.EstadoTextToInsight is EstadoTextToInsight + + +# --------------------------------------------------------------------------- # +# Construção e defaults do engine +# --------------------------------------------------------------------------- # + +def test_engine_constroi_com_defaults_documentados(patched_runtime): + """O construtor aceita só os campos obrigatórios e aplica defaults sensatos.""" + engine = InsightEngine( + api_key="fake-key", + model="gemini-2.5-flash", + db_path="data/olist_relational.db", + ) + assert engine._hitl_ativado is False + assert engine._show_output is False + assert engine._db_path == "data/olist_relational.db" + + +# --------------------------------------------------------------------------- # +# `run` — caminho feliz +# --------------------------------------------------------------------------- # + +def test_run_pergunta_clara_retorna_resultado_aprovado(patched_runtime): + """Com HITL off e pergunta clara, `run` devolve um dict aprovado completo.""" + engine = _fabricar_engine(hitl=False) + + resultado = engine.run(thread_id="t_feliz", query="Quantos pedidos existem?") + + assert resultado["status"] == "aprovado" + assert resultado["sql_gerada"] == "SELECT 1 AS total" + assert resultado["resposta_natural"].startswith("Existe") + assert resultado["linhas_resultado_preview"] == [{"total": 1}] + assert resultado["total_linhas_resultado"] == 1 + + +# --------------------------------------------------------------------------- # +# `run` — contrato do payload AWAITING_USER +# --------------------------------------------------------------------------- # + +def test_run_sem_callback_devolve_awaiting_user_com_payload_documentado(patched_runtime): + """O Ata 6 define esse contrato: status+message+chat_history+thread_id.""" + engine = _fabricar_engine(hitl=True) + + resultado = engine.run(thread_id="t_hitl_pause", query="consulta com hitl ambigua") + + assert resultado["status"] == "AWAITING_USER" + assert resultado["thread_id"] == "t_hitl_pause" + assert resultado["message"] == "Pode confirmar os filtros da consulta?" + assert resultado["chat_history"] == [] + assert set(resultado.keys()) == {"status", "message", "chat_history", "thread_id"} + + +# --------------------------------------------------------------------------- # +# `resume` — continuação após uma pausa HITL +# --------------------------------------------------------------------------- # + +def test_resume_retoma_fluxo_pausado_e_registra_historico(patched_runtime): + engine = _fabricar_engine(hitl=True) + engine.run(thread_id="t_resume", query="consulta com hitl ambigua") + + resultado = engine.resume(thread_id="t_resume", user_response="Sim, pode prosseguir") + + assert resultado["status"] == "aprovado" + assert resultado["espera_humana"] is False + historico = resultado.get("historico_conversa", []) + assert len(historico) == 1 + pergunta_ai, resposta_user = historico[0] + assert "Pode confirmar" in pergunta_ai + assert "prosseguir" in resposta_user + + +# --------------------------------------------------------------------------- # +# `on_human_prompt` — consumer supplies a callback to resolve the pause inline +# --------------------------------------------------------------------------- # + +def test_run_com_callback_resolve_pausa_sem_expor_awaiting_user(patched_runtime): + """Quando o consumidor passa `on_human_prompt`, a engine não precisa + devolver AWAITING_USER: ela chama o callback e segue até aprovação.""" + chamadas: list[str] = [] + + def callback(pergunta_agente: str) -> str: + chamadas.append(pergunta_agente) + return "ok, prossiga" + + engine = _fabricar_engine(hitl=True) + resultado = engine.run( + thread_id="t_callback", + query="consulta com hitl ambigua", + on_human_prompt=callback, + ) + + assert resultado["status"] == "aprovado" + assert chamadas == ["Pode confirmar os filtros da consulta?"] + + +# --------------------------------------------------------------------------- # +# Isolamento entre threads +# --------------------------------------------------------------------------- # + +def test_threads_distintas_nao_compartilham_historico(patched_runtime): + """Duas threads coexistem: pausa em uma não polui a outra.""" + engine = _fabricar_engine(hitl=True) + engine.run(thread_id="t_A", query="consulta com hitl ambigua") # pausa + + resultado_b = engine.run(thread_id="t_B", query="pergunta clara e direta") + + assert resultado_b["status"] == "aprovado" + assert resultado_b.get("historico_conversa", []) == [] + + +# --------------------------------------------------------------------------- # +# HITL desligado + pergunta ambígua → bloqueio com contrato documentado +# --------------------------------------------------------------------------- # + +def test_run_com_hitl_off_bloqueia_quando_planner_pede_humano(patched_runtime): + """Com HITL off, se o planner ainda pedir humano, o engine bloqueia em vez + de travar — e o payload explica o motivo.""" + engine = _fabricar_engine(hitl=False) + + resultado = engine.run(thread_id="t_block", query="consulta com hitl ambigua") + + assert resultado["status"] == "bloqueado_hitl" + assert "intervenção humana" in resultado["erro_execucao"].lower() + assert "[HITL]" in resultado["saida_terminal"]