From a289a2fd04ad9b5320ff995048e278b52acd4931 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Sun, 20 Sep 2026 22:21:22 -0300 Subject: [PATCH 1/5] clean up requirements.txt by delegating to pyproject.toml as the single source of project dependencies --- requirements.txt | 139 +---------------------------------------------- 1 file changed, 2 insertions(+), 137 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0f39e3e..1da169c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,137 +1,2 @@ -annotated-doc==0.0.4 -annotated-types==0.7.0 -anyio==4.12.1 -attrs==26.1.0 -bcrypt==5.0.0 -build==1.5.0 -certifi==2026.2.25 -cffi==2.0.0 -charset-normalizer==3.4.6 -chromadb==1.5.9 -click==8.3.3 -contourpy==1.3.3 -cryptography==46.0.6 -cycler==0.12.1 -distro==1.9.0 -durationpy==0.10 -filelock==3.29.0 -filetype==1.2.0 -flatbuffers==25.12.19 -fonttools==4.63.0 -fsspec==2026.4.0 -google-auth==2.49.1 -google-genai==1.69.0 -googleapis-common-protos==1.75.0 -grpcio==1.80.0 -h11==0.16.0 -hf-xet==1.5.0 -httpcore==1.0.9 -httptools==0.7.1 -httpx==0.28.1 -huggingface_hub==1.15.0 -idna==3.11 -importlib_metadata==8.7.1 -importlib_resources==7.1.0 -iniconfig==2.3.0 -jiter==0.14.0 -jsonpatch==1.33 -jsonpointer==3.0.0 -jsonschema==4.26.0 -jsonschema-specifications==2025.9.1 -kiwisolver==1.5.0 -kubernetes==35.0.0 -langchain==1.2.13 -langchain-core==1.2.28 -langchain-google-genai==4.2.1 -langchain-openai==1.1.12 -langgraph==1.1.3 -langgraph-checkpoint==4.0.1 -langgraph-prebuilt==1.0.8 -langgraph-sdk==0.3.12 -langsmith==0.7.22 -markdown-it-py==4.2.0 -matplotlib==3.10.9 -mdurl==0.1.2 -mmh3==5.2.1 -networkx==3.6.1 -numpy==2.4.5 -oauthlib==3.3.1 -onnxruntime==1.26.0 -openai==2.31.0 -opentelemetry-api==1.41.1 -opentelemetry-exporter-otlp-proto-common==1.41.1 -opentelemetry-exporter-otlp-proto-grpc==1.41.1 -opentelemetry-proto==1.41.1 -opentelemetry-sdk==1.41.1 -opentelemetry-semantic-conventions==0.62b1 -orjson==3.11.7 -ormsgpack==1.12.2 -overrides==7.7.0 -packaging==26.0 -pandas==3.0.3 -pillow==12.2.0 -pluggy==1.6.0 -protobuf==6.33.6 -pyasn1==0.6.3 -pyasn1_modules==0.4.2 -pybase64==1.4.3 -pycparser==3.0 -pydantic==2.12.5 -pydantic-settings==2.14.1 -pydantic_core==2.41.5 -Pygments==2.19.2 -pyparsing==3.3.2 -PyPika==0.51.1 -pyproject_hooks==1.2.0 -pytest==9.0.2 -pytest-recording==0.13.4 -pytest-timeout==2.4.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.2.2 -PyYAML==6.0.3 -referencing==0.37.0 -regex==2026.4.4 -requests==2.32.5 -requests-oauthlib==2.0.0 -requests-toolbelt==1.0.0 -rich==15.0.0 -rpds-py==0.30.0 -shellingham==1.5.4 -six==1.17.0 -sniffio==1.3.1 -tabulate==0.10.0 -tenacity==9.1.4 --e git+https://github.com/gruporaia/TextToInsight.git@4a0f2b6ba12ce946a185c7c46c3100695d9a59fc#egg=text_to_insight -tiktoken==0.12.0 -tokenizers==0.23.1 -tqdm==4.67.3 -typer==0.25.1 -typing-inspection==0.4.2 -typing_extensions==4.15.0 -urllib3==2.6.3 -uuid_utils==0.14.1 -uvicorn==0.47.0 -uvloop==0.22.1 -vcrpy==8.1.1 -watchfiles==1.1.1 -websocket-client==1.9.0 -websockets==16.0 -wrapt==2.1.2 -xxhash==3.6.0 -zipp==3.23.1 -zstandard==0.25.0 -langgraph>=0.2.0 -langchain>=0.2.0 -langchain-core>=0.2.0 -python-dotenv>=1.0.0 -langchain-google-genai>=2.0.0 -langchain-openai>=0.1.0 -pandas>=2.0.0 -matplotlib>=3.8.0 -pytest>=9.0.2 -pytest-recording>=0.13.0 -pytest-timeout>=2.3.0 -numpy -tabulate>=0.9.0 -chromadb -networkx>=3.0 +# Delegates to pyproject.toml as the single source of truth, including dev dependencies. +-e .[dev] \ No newline at end of file From dc417d6aa5098f173d95408d3a565fd563a27857 Mon Sep 17 00:00:00 2001 From: LuizCorrei4 Date: Sun, 20 Sep 2026 22:29:29 -0300 Subject: [PATCH 2/5] Revert "clean up requirements.txt by delegating to pyproject.toml as the single source of project dependencies" This reverts commit a289a2fd04ad9b5320ff995048e278b52acd4931. --- requirements.txt | 139 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1da169c..0f39e3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,137 @@ -# Delegates to pyproject.toml as the single source of truth, including dev dependencies. --e .[dev] \ No newline at end of file +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==26.1.0 +bcrypt==5.0.0 +build==1.5.0 +certifi==2026.2.25 +cffi==2.0.0 +charset-normalizer==3.4.6 +chromadb==1.5.9 +click==8.3.3 +contourpy==1.3.3 +cryptography==46.0.6 +cycler==0.12.1 +distro==1.9.0 +durationpy==0.10 +filelock==3.29.0 +filetype==1.2.0 +flatbuffers==25.12.19 +fonttools==4.63.0 +fsspec==2026.4.0 +google-auth==2.49.1 +google-genai==1.69.0 +googleapis-common-protos==1.75.0 +grpcio==1.80.0 +h11==0.16.0 +hf-xet==1.5.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +huggingface_hub==1.15.0 +idna==3.11 +importlib_metadata==8.7.1 +importlib_resources==7.1.0 +iniconfig==2.3.0 +jiter==0.14.0 +jsonpatch==1.33 +jsonpointer==3.0.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kiwisolver==1.5.0 +kubernetes==35.0.0 +langchain==1.2.13 +langchain-core==1.2.28 +langchain-google-genai==4.2.1 +langchain-openai==1.1.12 +langgraph==1.1.3 +langgraph-checkpoint==4.0.1 +langgraph-prebuilt==1.0.8 +langgraph-sdk==0.3.12 +langsmith==0.7.22 +markdown-it-py==4.2.0 +matplotlib==3.10.9 +mdurl==0.1.2 +mmh3==5.2.1 +networkx==3.6.1 +numpy==2.4.5 +oauthlib==3.3.1 +onnxruntime==1.26.0 +openai==2.31.0 +opentelemetry-api==1.41.1 +opentelemetry-exporter-otlp-proto-common==1.41.1 +opentelemetry-exporter-otlp-proto-grpc==1.41.1 +opentelemetry-proto==1.41.1 +opentelemetry-sdk==1.41.1 +opentelemetry-semantic-conventions==0.62b1 +orjson==3.11.7 +ormsgpack==1.12.2 +overrides==7.7.0 +packaging==26.0 +pandas==3.0.3 +pillow==12.2.0 +pluggy==1.6.0 +protobuf==6.33.6 +pyasn1==0.6.3 +pyasn1_modules==0.4.2 +pybase64==1.4.3 +pycparser==3.0 +pydantic==2.12.5 +pydantic-settings==2.14.1 +pydantic_core==2.41.5 +Pygments==2.19.2 +pyparsing==3.3.2 +PyPika==0.51.1 +pyproject_hooks==1.2.0 +pytest==9.0.2 +pytest-recording==0.13.4 +pytest-timeout==2.4.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +PyYAML==6.0.3 +referencing==0.37.0 +regex==2026.4.4 +requests==2.32.5 +requests-oauthlib==2.0.0 +requests-toolbelt==1.0.0 +rich==15.0.0 +rpds-py==0.30.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +tabulate==0.10.0 +tenacity==9.1.4 +-e git+https://github.com/gruporaia/TextToInsight.git@4a0f2b6ba12ce946a185c7c46c3100695d9a59fc#egg=text_to_insight +tiktoken==0.12.0 +tokenizers==0.23.1 +tqdm==4.67.3 +typer==0.25.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +uuid_utils==0.14.1 +uvicorn==0.47.0 +uvloop==0.22.1 +vcrpy==8.1.1 +watchfiles==1.1.1 +websocket-client==1.9.0 +websockets==16.0 +wrapt==2.1.2 +xxhash==3.6.0 +zipp==3.23.1 +zstandard==0.25.0 +langgraph>=0.2.0 +langchain>=0.2.0 +langchain-core>=0.2.0 +python-dotenv>=1.0.0 +langchain-google-genai>=2.0.0 +langchain-openai>=0.1.0 +pandas>=2.0.0 +matplotlib>=3.8.0 +pytest>=9.0.2 +pytest-recording>=0.13.0 +pytest-timeout>=2.3.0 +numpy +tabulate>=0.9.0 +chromadb +networkx>=3.0 From be6e762f760f33c42df645674a3c377104704986 Mon Sep 17 00:00:00 2001 From: antoniocaarvalh Date: Sat, 26 Sep 2026 16:55:28 -0300 Subject: [PATCH 3/5] feat: add PostgreSQL and MySQL support alongside SQLite Adds native schema introspection (information_schema, no Java/SchemaCrawler required) and query execution (psycopg2/pymysql) for PostgreSQL and MySQL, routed via a new db_dialeto/db_config state field. The code-gen prompt now tells the LLM which dialect it's targeting instead of always assuming SQLite. SQLite behavior is unchanged (dialect defaults to sqlite when not specified). --- pyproject.toml | 2 + text_to_insight/InsightEngine.py | 28 +- text_to_insight/cli.py | 44 ++- .../nodes/code_agent/code_agent.py | 50 ++- text_to_insight/nodes/code_agent/code_sql.py | 169 +++++++++- text_to_insight/nodes/sandbox.py | 16 +- text_to_insight/nodes/schema.py | 305 ++++++++++++++---- text_to_insight/runtime.py | 11 +- text_to_insight/state.py | 3 + 9 files changed, 557 insertions(+), 71 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51b9179..6ae6c48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ dependencies = [ "networkx>=3.0", "chromadb>=1.5.9", "tabulate>=0.10.0", + "psycopg2-binary>=2.9.0", + "pymysql>=1.1.0", ] [project.scripts] diff --git a/text_to_insight/InsightEngine.py b/text_to_insight/InsightEngine.py index 120f132..6fa4dbc 100644 --- a/text_to_insight/InsightEngine.py +++ b/text_to_insight/InsightEngine.py @@ -16,7 +16,24 @@ class InsightEngine: - `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, enable_graphs: bool = True, use_cot: bool = True, use_data_exploration: bool = True, use_exploration_selector: str = "off", use_rag: bool = True, enrich_rag: bool = False, inferir_fks_virtuais: bool = False, usar_schemacrawler: bool = True): + def __init__( + self, + api_key: str, + model: str, + db_path: str, + hitl: bool = False, + show_output: bool = False, + enable_graphs: bool = True, + use_cot: bool = True, + use_data_exploration: bool = True, + use_exploration_selector: str = "off", + use_rag: bool = True, + enrich_rag: bool = False, + inferir_fks_virtuais: bool = False, + usar_schemacrawler: bool = True, + db_dialeto: str = "sqlite", + db_config: dict | None = None, + ): 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. @@ -25,6 +42,8 @@ def __init__(self, api_key: str, model: str, db_path: str, hitl: bool = False, s self._enrich_rag = enrich_rag self._inferir_fks_virtuais = inferir_fks_virtuais self._usar_schemacrawler = usar_schemacrawler + self._db_dialeto = db_dialeto + self._db_config = db_config self._model = model self._db_path = db_path self._use_cot = use_cot @@ -41,7 +60,7 @@ def __init__(self, api_key: str, model: str, db_path: str, hitl: bool = False, s use_data_exploration=self._use_data_exploration, use_exploration_selector=self._use_exploration_selector, use_rag=self._use_rag, - enrich_rag=self._enrich_rag + enrich_rag=self._enrich_rag, ) print(f"[CONFIG] HITL: {'ATIVADO' if self._hitl_ativado else 'DESATIVADO'}") @@ -130,7 +149,10 @@ def get_insight( ) # Caso 2: chamada nova (primeira execução para essa pergunta). elif query: - estado_execucao = construir_estado_inicial(query, self._db_path, self._inferir_fks_virtuais, self._usar_schemacrawler) + estado_execucao = construir_estado_inicial( + query, self._db_path, self._inferir_fks_virtuais, self._usar_schemacrawler, + db_dialeto=self._db_dialeto, db_config=self._db_config, + ) pergunta_exibicao = query # Caso 3: a thread já está pausada, mas ainda sem resposta do usuário. elif snapshot.next: diff --git a/text_to_insight/cli.py b/text_to_insight/cli.py index 37d89ae..f62e11f 100644 --- a/text_to_insight/cli.py +++ b/text_to_insight/cli.py @@ -57,6 +57,38 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: default="data/olist_relational.db", help="Caminho para o banco SQLite.", ) + parser.add_argument( + "--db-dialeto", + choices=["sqlite", "postgresql", "mysql"], + default="sqlite", + help="Dialeto do banco: sqlite, postgresql ou mysql. Padrão: sqlite.", + ) + parser.add_argument( + "--db-host", + default=None, + help="Host do banco (obrigatório se --db-dialeto != sqlite).", + ) + parser.add_argument( + "--db-port", + type=int, + default=None, + help="Porta do banco (padrão: 5432 para postgresql, 3306 para mysql).", + ) + parser.add_argument( + "--db-name", + default=None, + help="Nome do banco de dados remoto.", + ) + parser.add_argument( + "--db-user", + default=None, + help="Usuário do banco remoto.", + ) + parser.add_argument( + "--db-password", + default=None, + help="Senha do banco remoto.", + ) parser.add_argument( "--model", default="gpt-5-mini", @@ -122,11 +154,21 @@ def main(argv: list[str] | None = None) -> dict[str, Any]: f"Variável de ambiente '{args.api_key_env}' não encontrada. " "Configure a chave da API antes de executar." ) - + db_config = None + if args.db_dialeto != "sqlite": + db_config = { + "host": args.db_host, + "port": args.db_port, + "database": args.db_name, + "user": args.db_user, + "password": args.db_password, + } engine = InsightEngine( api_key=api_key, model=args.model, db_path=args.db_path, + db_dialeto=args.db_dialeto, + db_config=db_config, hitl=hitl_ativado, enrich_rag=enrich_rag_ativado, inferir_fks_virtuais=inferir_fks_ativado, diff --git a/text_to_insight/nodes/code_agent/code_agent.py b/text_to_insight/nodes/code_agent/code_agent.py index 3cd24ad..7211bd0 100644 --- a/text_to_insight/nodes/code_agent/code_agent.py +++ b/text_to_insight/nodes/code_agent/code_agent.py @@ -11,7 +11,48 @@ from ...state import EstadoTextToInsight from ...utils import extrair_tokens -PROMPT_TEMPLATE_COT = """Você é um especialista em SQL para bancos SQLite. +# Nome de exibição e observações de sintaxe por dialeto, injetados no prompt do +# LLM para que a SQL gerada use a sintaxe correta do banco alvo (não apenas SQLite). +_NOME_DIALETO = { + "sqlite": "SQLite", + "postgresql": "PostgreSQL", + "mysql": "MySQL", +} + +_NOTA_DIALETO = { + "sqlite": ( + "Sintaxe SQLite: use || para concatenar strings, strftime() para " + "datas/horas, e LIMIT/OFFSET para paginação. Identificadores não " + "precisam de aspas." + ), + "postgresql": ( + "Sintaxe PostgreSQL: use || para concatenar strings, EXTRACT()/" + "TO_CHAR() para datas/horas, e LIMIT/OFFSET para paginação. " + "Identificadores com maiúsculas ou caracteres especiais exigem " + "aspas duplas (ex: \"NomeColuna\"); NÃO use crases (`)." + ), + "mysql": ( + "Sintaxe MySQL: use CONCAT() para concatenar strings (NÃO use ||), " + "DATE_FORMAT()/EXTRACT() para datas/horas, e LIMIT/OFFSET para " + "paginação. Identificadores podem usar crases (`nome_coluna`); " + "NÃO use aspas duplas para identificadores." + ), +} + + +def _resolver_info_dialeto(estado: EstadoTextToInsight) -> tuple[str, str]: + """Resolve (nome_exibicao, nota_sintaxe) do dialeto configurado no estado. + + Default é sempre 'sqlite', preservando o comportamento anterior quando + `db_dialeto` não é informado (compatibilidade retroativa). + """ + dialeto = (estado.get("db_dialeto") or "sqlite").strip().lower() + nome = _NOME_DIALETO.get(dialeto, _NOME_DIALETO["sqlite"]) + nota = _NOTA_DIALETO.get(dialeto, _NOTA_DIALETO["sqlite"]) + return nome, nota + + +PROMPT_TEMPLATE_COT = """Você é um especialista em SQL para bancos {dialeto}. Sua tarefa: gerar UMA única consulta SQL SELECT que responda à pergunta do usuário, usando o schema do banco de dados fornecido abaixo. @@ -23,6 +64,7 @@ - Use nomes de tabelas e colunas EXATAMENTE como aparecem no schema. - Se a pergunta for ambígua, faça a interpretação mais razoável. - Use as estatísticas de dados (DATA EXPLORATION) para entender distribuições, formatos e valores reais das colunas. +- {nota_dialeto} === SCHEMA DO BANCO === {schema} @@ -47,7 +89,7 @@ Sua consulta SQL aqui ```""" -PROMPT_TEMPLATE_NO_COT = """Você é um especialista em SQL para bancos SQLite. +PROMPT_TEMPLATE_NO_COT = """Você é um especialista em SQL para bancos {dialeto}. Sua tarefa: gerar UMA única consulta SQL SELECT que responda à pergunta do usuário, usando o schema do banco de dados fornecido abaixo. @@ -59,6 +101,7 @@ - Use nomes de tabelas e colunas EXATAMENTE como aparecem no schema. - Se a pergunta for ambígua, faça a interpretação mais razoável. - Use as estatísticas de dados (DATA EXPLORATION) para entender distribuições, formatos e valores reais das colunas e raciocinar sobre a natureza do D. +- {nota_dialeto} === SCHEMA DO BANCO === {schema} @@ -124,10 +167,13 @@ def nos_nodo_agente_codigo(estado: EstadoTextToInsight, llm: ChatGoogleGenerativ print(f"[AGENTE_CODIGO] Gerando SQL (tentativa {tentativas + 1})...") historico_section = _formatar_historico_tentativas(historico) + dialeto_nome, nota_dialeto = _resolver_info_dialeto(estado) template = PROMPT_TEMPLATE_COT if use_cot else PROMPT_TEMPLATE_NO_COT prompt = template.format( + dialeto=dialeto_nome, + nota_dialeto=nota_dialeto, schema=schema_rag if schema_rag else schema, data_exploration=data_exploration if data_exploration else "Não disponível.", pergunta=pergunta, diff --git a/text_to_insight/nodes/code_agent/code_sql.py b/text_to_insight/nodes/code_agent/code_sql.py index 33b1977..9e2e534 100644 --- a/text_to_insight/nodes/code_agent/code_sql.py +++ b/text_to_insight/nodes/code_agent/code_sql.py @@ -1,5 +1,5 @@ """ -Nó para execução de SQL em SQLite. +Nó para execução de SQL em SQLite, PostgreSQL e MySQL. Este módulo NÃO gera Python dinamicamente Ele expõe funções utilitárias para validar e executar SQL em modo seguro @@ -7,7 +7,7 @@ """ from __future__ import annotations -import re +import re import sqlite3 from pathlib import Path from typing import Any @@ -142,4 +142,167 @@ def _progress_handler(): "linhas_resultado_completo": [], "total_linhas_resultado": 0, "saida_terminal": f"[SANDBOX] Erro de execucao: {e}", - } \ No newline at end of file + } + + +def _erro_execucao(msg: str) -> dict[str, Any]: + """Monta a estrutura de retorno padrao para uma falha de execucao.""" + return { + "ok": False, + "erro_execucao": msg, + "linhas_resultado_preview": [], + "linhas_resultado_completo": [], + "total_linhas_resultado": 0, + "saida_terminal": f"[SANDBOX] {msg}", + } + + +def _campos_obrigatorios_faltando(db_config: dict[str, Any], campos: tuple[str, ...]) -> str: + faltando = [c for c in campos if not db_config.get(c)] + if faltando: + return f"db_config incompleto, faltam: {', '.join(faltando)}" + return "" + + +def executar_sql_postgres( + db_config: dict[str, Any], + sql: str, + limite_preview: int = 5, + timeout_segundos: float = 15.0, +) -> dict[str, Any]: + """ + Executa SQL validada em PostgreSQL (read-only, via transacao somente leitura) + e retorna resultado estruturado no mesmo formato de `executar_sql_sqlite`. + + db_config esperado: {"host", "port" (opcional, default 5432), "database", "user", "password"}. + """ + ok, erro_validacao = validar_sql_segura(sql) + if not ok: + return _erro_execucao(f"SQL invalida: {erro_validacao}") + + faltando = _campos_obrigatorios_faltando(db_config, ("host", "database", "user", "password")) + if faltando: + return _erro_execucao(faltando) + + import psycopg2 + import psycopg2.extras + + try: + conn = psycopg2.connect( + host=db_config["host"], + port=db_config.get("port", 5432), + dbname=db_config["database"], + user=db_config["user"], + password=db_config["password"], + connect_timeout=int(timeout_segundos), + ) + try: + conn.set_session(readonly=True, autocommit=True) + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(f"SET statement_timeout = {int(timeout_segundos * 1000)}") + cur.execute(sql) + rows = [dict(r) for r in cur.fetchall()] + finally: + conn.close() + except psycopg2.errors.QueryCanceled: + return _erro_execucao(f"Query abortada por timeout (> {timeout_segundos}s).") + except Exception as e: + return _erro_execucao(f"Falha ao executar SQL: {e}") + + total = len(rows) + return { + "ok": True, + "erro_execucao": "", + "linhas_resultado_preview": rows[:limite_preview], + "linhas_resultado_completo": rows, + "total_linhas_resultado": total, + "saida_terminal": ( + f"[SANDBOX] Execucao OK | linhas_total={total} " + f"| preview={min(total, limite_preview)}" + ), + } + + +def executar_sql_mysql( + db_config: dict[str, Any], + sql: str, + limite_preview: int = 5, + timeout_segundos: float = 15.0, +) -> dict[str, Any]: + """ + Executa SQL validada em MySQL (conexao read-only) e retorna resultado + estruturado no mesmo formato de `executar_sql_sqlite`. + + db_config esperado: {"host", "port" (opcional, default 3306), "database", "user", "password"}. + """ + ok, erro_validacao = validar_sql_segura(sql) + if not ok: + return _erro_execucao(f"SQL invalida: {erro_validacao}") + + faltando = _campos_obrigatorios_faltando(db_config, ("host", "database", "user", "password")) + if faltando: + return _erro_execucao(faltando) + + import pymysql + import pymysql.cursors + + try: + conn = pymysql.connect( + host=db_config["host"], + port=db_config.get("port", 3306), + database=db_config["database"], + user=db_config["user"], + password=db_config["password"], + connect_timeout=int(timeout_segundos), + read_timeout=int(timeout_segundos), + cursorclass=pymysql.cursors.DictCursor, + ) + try: + with conn.cursor() as cur: + cur.execute("SET SESSION TRANSACTION READ ONLY") + cur.execute(f"SET SESSION max_execution_time = {int(timeout_segundos * 1000)}") + cur.execute(sql) + rows = list(cur.fetchall()) + finally: + conn.close() + except pymysql.err.OperationalError as e: + erro_msg = str(e) + if "1317" in erro_msg or "max_execution_time" in erro_msg.lower(): + return _erro_execucao(f"Query abortada por timeout (> {timeout_segundos}s).") + return _erro_execucao(f"Falha ao executar SQL: {erro_msg}") + except Exception as e: + return _erro_execucao(f"Falha ao executar SQL: {e}") + + total = len(rows) + return { + "ok": True, + "erro_execucao": "", + "linhas_resultado_preview": rows[:limite_preview], + "linhas_resultado_completo": rows, + "total_linhas_resultado": total, + "saida_terminal": ( + f"[SANDBOX] Execucao OK | linhas_total={total} " + f"| preview={min(total, limite_preview)}" + ), + } + + +def executar_sql( + dialeto: str, + sql: str, + db_path: str = "", + db_config: dict[str, Any] | None = None, + limite_preview: int = 5, + timeout_segundos: float = 15.0, +) -> dict[str, Any]: + """ + Dispatcher unico do executor: roteia para o backend correto conforme `dialeto`. + + - "sqlite" (padrao): usa `db_path` (arquivo local). + - "postgresql" / "mysql": usa `db_config` (host/port/database/user/password). + """ + if dialeto == "postgresql": + return executar_sql_postgres(db_config or {}, sql, limite_preview, timeout_segundos) + if dialeto == "mysql": + return executar_sql_mysql(db_config or {}, sql, limite_preview, timeout_segundos) + return executar_sql_sqlite(db_path, sql, limite_preview, timeout_segundos) \ No newline at end of file diff --git a/text_to_insight/nodes/sandbox.py b/text_to_insight/nodes/sandbox.py index a960490..b2ef537 100644 --- a/text_to_insight/nodes/sandbox.py +++ b/text_to_insight/nodes/sandbox.py @@ -6,20 +6,24 @@ """ from ..state import EstadoTextToInsight -from .code_agent.code_sql import executar_sql_sqlite +from .code_agent.code_sql import executar_sql def nos_nodo_sandbox(estado: EstadoTextToInsight) -> dict: """ - Nó Executor: executa a SQL gerada contra o banco SQLite real. + Nó Executor: executa a SQL gerada contra o banco real (SQLite, PostgreSQL ou MySQL). - Lê `sql_gerada` e `db_path` do estado, delega para `executar_sql_sqlite` - (que já faz validação de segurança) e retorna o resultado estruturado. + Lê `sql_gerada`, `db_path`/`db_config` e `db_dialeto` do estado, delega para + `executar_sql` (que já faz validação de segurança e roteia por dialeto) e + retorna o resultado estruturado. """ sql = estado.get("sql_gerada", "").strip() db_path = estado.get("db_path", "") + db_config = estado.get("db_config") or {} + dialeto = estado.get("db_dialeto", "sqlite") - print(f"[EXECUTOR] Executando SQL contra {db_path}...") + alvo = db_path if dialeto == "sqlite" else f"{dialeto}://{db_config.get('host', '?')}/{db_config.get('database', '?')}" + print(f"[EXECUTOR] Executando SQL contra {alvo}...") if not sql: print("[EXECUTOR] Nenhuma SQL encontrada no estado.") @@ -29,7 +33,7 @@ def nos_nodo_sandbox(estado: EstadoTextToInsight) -> dict: "status": "exec_erro", } - resultado = executar_sql_sqlite(db_path, sql) + resultado = executar_sql(dialeto, sql, db_path=db_path, db_config=db_config) if resultado["ok"]: print(f"[EXECUTOR] SQL executada com sucesso — {resultado['total_linhas_resultado']} linhas.") diff --git a/text_to_insight/nodes/schema.py b/text_to_insight/nodes/schema.py index d23a4ee..31ba48b 100644 --- a/text_to_insight/nodes/schema.py +++ b/text_to_insight/nodes/schema.py @@ -49,6 +49,16 @@ f"--password={cfg['password']}", ] }, + "mysql": { + "args": lambda _db, cfg: [ + "--server=mysql", + f"--host={cfg['host']}", + f"--port={cfg.get('port', 3306)}", + f"--database={cfg['database']}", + f"--user={cfg['user']}", + f"--password={cfg['password']}", + ] + }, } _EXTENSAO_PARA_DIALETO = { @@ -383,6 +393,205 @@ def _formatar_schema_sqlite(conn: sqlite3.Connection) -> str: return "\n".join(partes) + +# --------------------------------------------------------------------------- +# Introspecção PostgreSQL nativa (fallback sem Schema Crawler) +# --------------------------------------------------------------------------- + +def _formatar_schema_postgres(conn) -> str: + """ + Constrói representação textual do schema via information_schema (PostgreSQL). + Formato compatível com enrich_schema.py e SchemaGraphRAG. + """ + cursor = conn.cursor() + cursor.execute( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name + """ + ) + tabelas = [row[0] for row in cursor.fetchall()] + + partes = ["=== SCHEMA POSTGRESQL (INTROSPECCAO REAL) ===", ""] + + if not tabelas: + partes.append("Nenhuma tabela encontrada no banco.") + return "\n".join(partes) + + for tabela in tabelas: + partes.append(f"Tabela: {tabela}") + + cursor.execute( + """ + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position + """, + (tabela,), + ) + colunas = cursor.fetchall() + + cursor.execute( + """ + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = 'public' AND tc.table_name = %s + """, + (tabela,), + ) + pks = {row[0] for row in cursor.fetchall()} + + if colunas: + for nome, tipo, is_nullable in colunas: + flags = [] + if nome in pks: + flags.append("PK") + if is_nullable == "NO": + flags.append("NOT NULL") + sufixo = f" ({', '.join(flags)})" if flags else "" + partes.append(f"- {nome}: {tipo}{sufixo}") + else: + partes.append("- [sem colunas detectadas]") + + cursor.execute( + """ + SELECT kcu.column_name, ccu.table_name, ccu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' AND tc.table_name = %s + """, + (tabela,), + ) + fks = cursor.fetchall() + if fks: + partes.append(" Foreign keys:") + for col_origem, tabela_ref, col_destino in fks: + partes.append(f" - {col_origem} -> {tabela_ref}.{col_destino}") + + partes.append("") + + return "\n".join(partes) + + +# --------------------------------------------------------------------------- +# Introspecção MySQL nativa (fallback sem Schema Crawler) +# --------------------------------------------------------------------------- + +def _formatar_schema_mysql(conn) -> str: + """ + Constrói representação textual do schema via information_schema (MySQL). + Formato compatível com enrich_schema.py e SchemaGraphRAG. + """ + cursor = conn.cursor() + cursor.execute( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' + ORDER BY table_name + """ + ) + tabelas = [row[0] for row in cursor.fetchall()] + + partes = ["=== SCHEMA MYSQL (INTROSPECCAO REAL) ===", ""] + + if not tabelas: + partes.append("Nenhuma tabela encontrada no banco.") + return "\n".join(partes) + + for tabela in tabelas: + partes.append(f"Tabela: {tabela}") + + cursor.execute( + """ + SELECT column_name, data_type, is_nullable, column_key + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = %s + ORDER BY ordinal_position + """, + (tabela,), + ) + colunas = cursor.fetchall() + + if colunas: + for nome, tipo, is_nullable, column_key in colunas: + flags = [] + if column_key == "PRI": + flags.append("PK") + if is_nullable == "NO": + flags.append("NOT NULL") + sufixo = f" ({', '.join(flags)})" if flags else "" + partes.append(f"- {nome}: {tipo}{sufixo}") + else: + partes.append("- [sem colunas detectadas]") + + cursor.execute( + """ + SELECT column_name, referenced_table_name, referenced_column_name + FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() AND table_name = %s + AND referenced_table_name IS NOT NULL + """, + (tabela,), + ) + fks = cursor.fetchall() + if fks: + partes.append(" Foreign keys:") + for col_origem, tabela_ref, col_destino in fks: + partes.append(f" - {col_origem} -> {tabela_ref}.{col_destino}") + + partes.append("") + + return "\n".join(partes) + + +def _introspeccao_nativa(dialeto: str, caminho_db: Path | None, db_cfg: dict) -> str: + """Abre conexão e roteia para o formatador de schema nativo do dialeto.""" + if dialeto == "postgresql": + import psycopg2 + conn = psycopg2.connect( + host=db_cfg["host"], + port=db_cfg.get("port", 5432), + dbname=db_cfg["database"], + user=db_cfg["user"], + password=db_cfg["password"], + ) + try: + return _formatar_schema_postgres(conn) + finally: + conn.close() + + if dialeto == "mysql": + import pymysql + conn = pymysql.connect( + host=db_cfg["host"], + port=db_cfg.get("port", 3306), + database=db_cfg["database"], + user=db_cfg["user"], + password=db_cfg["password"], + ) + try: + return _formatar_schema_mysql(conn) + finally: + conn.close() + + # sqlite / duckdb / fallback padrão + conn = sqlite3.connect(f"file:{caminho_db}?mode=ro", uri=True) + with conn: + return _formatar_schema_sqlite(conn) + + # --------------------------------------------------------------------------- # Heurística para analisar a estrutura do schema e injegar relações implícitas # --------------------------------------------------------------------------- @@ -552,42 +761,46 @@ def nos_nodo_esquema(estado: EstadoTextToInsight) -> dict: """ db_path = estado.get("db_path", "").strip() sc_bin = estado.get("schemacrawler_bin", "").strip() - db_cfg = estado.get("db_config", {}) + db_cfg = estado.get("db_config") or {} usar_schemacrawler = estado.get("usar_schemacrawler", True) inferir_fks_virtuais = estado.get("inferir_fks_virtuais", False) + dialeto = estado.get("db_dialeto") or _detectar_dialeto(db_path) # --- Validação --- - if not db_path: - msg = "db_path não informado no estado." - print(f"[SCHEMA] Erro: {msg}") - return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} - - caminho_db = Path(db_path) - dialeto = _detectar_dialeto(db_path) - - # Para bancos locais, valida existência do arquivo - if dialeto in ("sqlite", "duckdb") and not caminho_db.exists(): - msg = f"Arquivo de banco não encontrado: {db_path}" - print(f"[SCHEMA] Erro: {msg}") - return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} - - # --- Cache enriquecido --- - # Nome inclui dialeto para evitar colisão entre bancos diferentes - cache_path = caminho_db.with_name( - f"{caminho_db.stem}_{dialeto}_enriched_schema.txt" - ) - # Compatibilidade com cache gerado antes da separação por dialeto - cache_legado = caminho_db.with_name(f"{caminho_db.stem}_enriched_schema.txt") - - for cache in (cache_path, cache_legado): - if cache.exists(): - print(f"[SCHEMA] Cache enriquecido encontrado: {cache.name}") - return { - "contexto_schema": cache.read_text(encoding="utf-8"), - "erro_execucao": "", - "status": "schema_obtido", - "tem_descricao": True, - } + caminho_db: Path | None = None + + if dialeto in ("sqlite", "duckdb"): + if not db_path: + msg = "db_path não informado no estado." + print(f"[SCHEMA] Erro: {msg}") + return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} + caminho_db = Path(db_path) + if not caminho_db.exists(): + msg = f"Arquivo de banco não encontrado: {db_path}" + print(f"[SCHEMA] Erro: {msg}") + return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} + else: + campos_obrigatorios = ("host", "database", "user", "password") + faltando = [c for c in campos_obrigatorios if not db_cfg.get(c)] + if faltando: + msg = f"db_config incompleto para dialeto '{dialeto}', faltam: {', '.join(faltando)}" + print(f"[SCHEMA] Erro: {msg}") + return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} + + # --- Cache enriquecido (apenas bancos locais por enquanto) --- + if caminho_db is not None: + cache_path = caminho_db.with_name(f"{caminho_db.stem}_{dialeto}_enriched_schema.txt") + cache_legado = caminho_db.with_name(f"{caminho_db.stem}_enriched_schema.txt") + + for cache in (cache_path, cache_legado): + if cache.exists(): + print(f"[SCHEMA] Cache enriquecido encontrado: {cache.name}") + return { + "contexto_schema": cache.read_text(encoding="utf-8"), + "erro_execucao": "", + "status": "schema_obtido", + "tem_descricao": True, + } # --- Introspecção --- contexto: str @@ -599,32 +812,14 @@ def nos_nodo_esquema(estado: EstadoTextToInsight) -> dict: contexto = _rodar_schemacrawler(db_path, dialeto, sc_bin, db_cfg) print("[SCHEMA] Schema Crawler: introspecção concluída.") except Exception as e: - print(f"[SCHEMA] Schema Crawler falhou ({e}). Tentando fallback...") - if dialeto == "sqlite": - conn = sqlite3.connect(f"file:{caminho_db}?mode=ro", uri=True) - with conn: - contexto = _formatar_schema_sqlite(conn) - print("[SCHEMA] Fallback PRAGMA SQLite aplicado.") - else: - msg = f"Schema Crawler falhou para dialeto '{dialeto}' e não há fallback: {e}" - print(f"[SCHEMA] Erro: {msg}") - return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} + print(f"[SCHEMA] Schema Crawler falhou ({e}). Tentando fallback nativo...") + contexto = _introspeccao_nativa(dialeto, caminho_db, db_cfg) else: - if dialeto != "sqlite": - msg = ( - f"Dialeto '{dialeto}' requer Schema Crawler, mas schemacrawler_bin " - f"não está configurado (ou usar_schemacrawler está desativado)." - ) - print(f"[SCHEMA] Erro: {msg}") - return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} - if not usar_schemacrawler: - print("[SCHEMA] usar_schemacrawler desativado — usando PRAGMA SQLite.") + print(f"[SCHEMA] usar_schemacrawler desativado — usando introspecção nativa ({dialeto}).") else: - print("[SCHEMA] schemacrawler_bin não configurado — usando PRAGMA SQLite.") - conn = sqlite3.connect(f"file:{caminho_db}?mode=ro", uri=True) - with conn: - contexto = _formatar_schema_sqlite(conn) + print(f"[SCHEMA] schemacrawler_bin não configurado — usando introspecção nativa ({dialeto}).") + contexto = _introspeccao_nativa(dialeto, caminho_db, db_cfg) # --- Heurística de FKs virtuais --- if inferir_fks_virtuais: diff --git a/text_to_insight/runtime.py b/text_to_insight/runtime.py index 33b8a32..1300c4e 100644 --- a/text_to_insight/runtime.py +++ b/text_to_insight/runtime.py @@ -41,7 +41,14 @@ """ -def construir_estado_inicial(pergunta: str, db_path: str, inferir_fks_virtuais: bool = False, usar_schemacrawler: bool = True) -> dict[str, Any]: +def construir_estado_inicial( + pergunta: str, + db_path: str, + inferir_fks_virtuais: bool = False, + usar_schemacrawler: bool = True, + db_dialeto: str = "sqlite", + db_config: dict[str, Any] | None = None, +) -> dict[str, Any]: """Cria o estado inicial padrão para uma execução do grafo.""" return { "pergunta_original": pergunta, @@ -55,6 +62,8 @@ def construir_estado_inicial(pergunta: str, db_path: str, inferir_fks_virtuais: "status": "iniciado", "tentativas_loop": 0, "db_path": db_path, + "db_dialeto": db_dialeto, + "db_config": db_config, "espera_humana": False, "linhas_resultado_completo": [], "historico_tentativas": [], diff --git a/text_to_insight/state.py b/text_to_insight/state.py index 1f05128..e9008d7 100644 --- a/text_to_insight/state.py +++ b/text_to_insight/state.py @@ -25,6 +25,8 @@ "reprovado", ] +DialetoBanco = Literal["sqlite", "postgresql", "mysql"] + # Criação de classe mãe que será estendida para EstadoTextToInsight para que # pergunta_original/pergunta_atual e db_path sejam obrigatorios class EstadoEntrada(TypedDict): @@ -75,6 +77,7 @@ class EstadoTextToInsight(EstadoEntrada, total = False): historico_tentativas: Annotated[list[dict[str, str]], operator.add] linhas_resultado_completo: list[dict[str, Any]] schemacrawler_bin : str | None + db_dialeto: DialetoBanco db_config: dict[str, Any] | None inferir_fks_virtuais: bool usar_schemacrawler: bool From ec68b4cb3be8ce5799418638db833b275ed3b1c0 Mon Sep 17 00:00:00 2001 From: antoniocaarvalh Date: Sun, 27 Sep 2026 18:48:13 -0300 Subject: [PATCH 4/5] feat: add SQLAlchemy-based db_url for automatic dialect detection Adds an optional db_url field (e.g. postgresql://user:pass@host:port/db) that, when set, drives schema introspection and query execution through SQLAlchemy instead of the manual db_dialeto/db_config path added earlier. The dialect is read from engine.dialect.name, so the caller only needs to provide one connection string instead of six separate flags/params. This is purely additive: when db_url is not set, behavior is unchanged (same db_dialeto/db_config/db_path path as before). --- pyproject.toml | 1 + text_to_insight/InsightEngine.py | 4 +- text_to_insight/cli.py | 11 ++++ text_to_insight/nodes/code_agent/code_sql.py | 53 ++++++++++++++++- text_to_insight/nodes/sandbox.py | 13 ++++- text_to_insight/nodes/schema.py | 60 ++++++++++++++++++++ text_to_insight/runtime.py | 2 + text_to_insight/state.py | 1 + 8 files changed, 140 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ae6c48..bb667a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "tabulate>=0.10.0", "psycopg2-binary>=2.9.0", "pymysql>=1.1.0", + "sqlalchemy>=2.0.0", ] [project.scripts] diff --git a/text_to_insight/InsightEngine.py b/text_to_insight/InsightEngine.py index 6fa4dbc..4639620 100644 --- a/text_to_insight/InsightEngine.py +++ b/text_to_insight/InsightEngine.py @@ -33,6 +33,7 @@ def __init__( usar_schemacrawler: bool = True, db_dialeto: str = "sqlite", db_config: dict | None = None, + db_url: str = "", ): self._hitl_ativado = hitl # `show_output` controla se a engine imprime o resultado final no terminal. @@ -44,6 +45,7 @@ def __init__( self._usar_schemacrawler = usar_schemacrawler self._db_dialeto = db_dialeto self._db_config = db_config + self._db_url = db_url self._model = model self._db_path = db_path self._use_cot = use_cot @@ -151,7 +153,7 @@ def get_insight( elif query: estado_execucao = construir_estado_inicial( query, self._db_path, self._inferir_fks_virtuais, self._usar_schemacrawler, - db_dialeto=self._db_dialeto, db_config=self._db_config, + db_dialeto=self._db_dialeto, db_config=self._db_config, db_url=self._db_url, ) pergunta_exibicao = query # Caso 3: a thread já está pausada, mas ainda sem resposta do usuário. diff --git a/text_to_insight/cli.py b/text_to_insight/cli.py index f62e11f..dacac2d 100644 --- a/text_to_insight/cli.py +++ b/text_to_insight/cli.py @@ -89,6 +89,16 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=None, help="Senha do banco remoto.", ) + parser.add_argument( + "--db-url", + default=os.getenv("DATABASE_URL"), + help=( + "URL unica de conexao (ex: postgresql://usuario:senha@host:5432/banco). " + "Se informado, o dialeto e detectado automaticamente pelo SQLAlchemy e as " + "demais flags --db-dialeto/--db-host/--db-port/--db-name/--db-user/--db-password " + "sao ignoradas. Tambem pode vir da variavel de ambiente DATABASE_URL." + ), + ) parser.add_argument( "--model", default="gpt-5-mini", @@ -169,6 +179,7 @@ def main(argv: list[str] | None = None) -> dict[str, Any]: db_path=args.db_path, db_dialeto=args.db_dialeto, db_config=db_config, + db_url=args.db_url or "", hitl=hitl_ativado, enrich_rag=enrich_rag_ativado, inferir_fks_virtuais=inferir_fks_ativado, diff --git a/text_to_insight/nodes/code_agent/code_sql.py b/text_to_insight/nodes/code_agent/code_sql.py index 9e2e534..5aa674d 100644 --- a/text_to_insight/nodes/code_agent/code_sql.py +++ b/text_to_insight/nodes/code_agent/code_sql.py @@ -305,4 +305,55 @@ def executar_sql( return executar_sql_postgres(db_config or {}, sql, limite_preview, timeout_segundos) if dialeto == "mysql": return executar_sql_mysql(db_config or {}, sql, limite_preview, timeout_segundos) - return executar_sql_sqlite(db_path, sql, limite_preview, timeout_segundos) \ No newline at end of file + return executar_sql_sqlite(db_path, sql, limite_preview, timeout_segundos) + + +def executar_sql_via_url( + db_url: str, + sql: str, + limite_preview: int = 5, + timeout_segundos: float = 15.0, +) -> dict[str, Any]: + """ + Executa SQL usando uma unica URL de conexao (SQLAlchemy detecta o + dialeto sozinho a partir do prefixo da URL: sqlite:///, postgresql://, + mysql+pymysql://, etc). Alternativa mais simples ao `executar_sql` + quando o estado traz `db_url` em vez de `db_dialeto`/`db_config`. + """ + ok, erro_validacao = validar_sql_segura(sql) + if not ok: + return _erro_execucao(f"SQL invalida: {erro_validacao}") + + from sqlalchemy import create_engine, text + + engine = create_engine(db_url) + try: + opcoes = {} + if engine.dialect.name == "postgresql": + opcoes["postgresql_readonly"] = True + + with engine.connect() as conn: + conn = conn.execution_options(**opcoes) if opcoes else conn + + if engine.dialect.name == "mysql": + conn.execute(text("SET SESSION TRANSACTION READ ONLY")) + + resultado = conn.execute(text(sql)) + rows = [dict(linha) for linha in resultado.mappings().all()] + except Exception as e: + return _erro_execucao(f"Falha ao executar SQL: {e}") + finally: + engine.dispose() + + total = len(rows) + return { + "ok": True, + "erro_execucao": "", + "linhas_resultado_preview": rows[:limite_preview], + "linhas_resultado_completo": rows, + "total_linhas_resultado": total, + "saida_terminal": ( + f"[SANDBOX] Execucao OK | linhas_total={total} " + f"| preview={min(total, limite_preview)}" + ), + } \ No newline at end of file diff --git a/text_to_insight/nodes/sandbox.py b/text_to_insight/nodes/sandbox.py index b2ef537..74eea58 100644 --- a/text_to_insight/nodes/sandbox.py +++ b/text_to_insight/nodes/sandbox.py @@ -6,7 +6,7 @@ """ from ..state import EstadoTextToInsight -from .code_agent.code_sql import executar_sql +from .code_agent.code_sql import executar_sql, executar_sql_via_url def nos_nodo_sandbox(estado: EstadoTextToInsight) -> dict: @@ -22,7 +22,11 @@ def nos_nodo_sandbox(estado: EstadoTextToInsight) -> dict: db_config = estado.get("db_config") or {} dialeto = estado.get("db_dialeto", "sqlite") - alvo = db_path if dialeto == "sqlite" else f"{dialeto}://{db_config.get('host', '?')}/{db_config.get('database', '?')}" + db_url = estado.get("db_url", "").strip() + + alvo = "banco via db_url (dialeto auto-detectado)" if db_url else ( + db_path if dialeto == "sqlite" else f"{dialeto}://{db_config.get('host', '?')}/{db_config.get('database', '?')}" + ) print(f"[EXECUTOR] Executando SQL contra {alvo}...") if not sql: @@ -33,7 +37,10 @@ def nos_nodo_sandbox(estado: EstadoTextToInsight) -> dict: "status": "exec_erro", } - resultado = executar_sql(dialeto, sql, db_path=db_path, db_config=db_config) + if db_url: + resultado = executar_sql_via_url(db_url, sql) + else: + resultado = executar_sql(dialeto, sql, db_path=db_path, db_config=db_config) if resultado["ok"]: print(f"[EXECUTOR] SQL executada com sucesso — {resultado['total_linhas_resultado']} linhas.") diff --git a/text_to_insight/nodes/schema.py b/text_to_insight/nodes/schema.py index 31ba48b..1f6702f 100644 --- a/text_to_insight/nodes/schema.py +++ b/text_to_insight/nodes/schema.py @@ -591,6 +591,51 @@ def _introspeccao_nativa(dialeto: str, caminho_db: Path | None, db_cfg: dict) -> with conn: return _formatar_schema_sqlite(conn) +def _formatar_schema_sqlalchemy(engine) -> str: + from sqlalchemy import inspect + + insp = inspect(engine) + dialeto = engine.dialect.name + tabelas = insp.get_table_names() + + partes = [f"=== SCHEMA {dialeto.upper()} (SQLALCHEMY) ===", ""] + + if not tabelas: + partes.append("Nenhuma tabela encontrada no banco.") + return "\n".join(partes) + + for tabela in sorted(tabelas): + partes.append(f"Tabela: {tabela}") + + pk_cols = set(insp.get_pk_constraint(tabela).get("constrained_columns") or []) + colunas = insp.get_columns(tabela) + + if colunas: + for col in colunas: + flags = [] + if col["name"] in pk_cols: + flags.append("PK") + if not col.get("nullable", True): + flags.append("NOT NULL") + sufixo = f" ({', '.join(flags)})" if flags else "" + partes.append(f"- {col['name']}: {col['type']}{sufixo}") + else: + partes.append("- [sem colunas detectadas]") + + fks = insp.get_foreign_keys(tabela) + if fks: + partes.append(" Foreign keys:") + for fk in fks: + cols_origem = fk.get("constrained_columns") or [] + tabela_ref = fk.get("referred_table") + cols_destino = fk.get("referred_columns") or [] + for col_origem, col_destino in zip(cols_origem, cols_destino): + partes.append(f" - {col_origem} -> {tabela_ref}.{col_destino}") + + partes.append("") + + return "\n".join(partes) + # --------------------------------------------------------------------------- # Heurística para analisar a estrutura do schema e injegar relações implícitas @@ -759,6 +804,21 @@ def nos_nodo_esquema(estado: EstadoTextToInsight) -> dict: status (str) tem_descricao (bool) """ + db_url = estado.get("db_url", "").strip() + if db_url: + from sqlalchemy import create_engine + + engine = create_engine(db_url) + try: + contexto = _formatar_schema_sqlalchemy(engine) + finally: + engine.dispose() + return { + "contexto_schema": contexto, + "erro_execucao": "", + "status": "schema_obtido", + "tem_descricao": False, + } db_path = estado.get("db_path", "").strip() sc_bin = estado.get("schemacrawler_bin", "").strip() db_cfg = estado.get("db_config") or {} diff --git a/text_to_insight/runtime.py b/text_to_insight/runtime.py index 1300c4e..05d46d1 100644 --- a/text_to_insight/runtime.py +++ b/text_to_insight/runtime.py @@ -48,6 +48,7 @@ def construir_estado_inicial( usar_schemacrawler: bool = True, db_dialeto: str = "sqlite", db_config: dict[str, Any] | None = None, + db_url: str = "", ) -> dict[str, Any]: """Cria o estado inicial padrão para uma execução do grafo.""" return { @@ -63,6 +64,7 @@ def construir_estado_inicial( "tentativas_loop": 0, "db_path": db_path, "db_dialeto": db_dialeto, + "db_url": db_url, "db_config": db_config, "espera_humana": False, "linhas_resultado_completo": [], diff --git a/text_to_insight/state.py b/text_to_insight/state.py index e9008d7..4505c1e 100644 --- a/text_to_insight/state.py +++ b/text_to_insight/state.py @@ -78,6 +78,7 @@ class EstadoTextToInsight(EstadoEntrada, total = False): linhas_resultado_completo: list[dict[str, Any]] schemacrawler_bin : str | None db_dialeto: DialetoBanco + db_url: str db_config: dict[str, Any] | None inferir_fks_virtuais: bool usar_schemacrawler: bool From fce2c88fde822ff62ceded0b6a70f45f370dfba2 Mon Sep 17 00:00:00 2001 From: antoniocaarvalh Date: Mon, 28 Sep 2026 16:48:24 -0300 Subject: [PATCH 5/5] fix: address security/robustness issues flagged by automated PR review - CLI --db-password now reads from DB_PASSWORD env var by default (avoids leaking secrets into shell history/process args) - validar_sql_segura blocks MySQL INTO OUTFILE/DUMPFILE and LOAD_FILE(), plus Postgres pg_read_file/pg_ls_dir/lo_import/lo_export and SQLite load_extension() -- these start with SELECT and previously slipped past the read-only check - state now redacts db_config/db_url passwords before returning results to callers (_redigir_credenciais), instead of leaking them in plain text - code-gen prompt now also resolves dialect from db_url when db_dialeto is unset, instead of always defaulting to SQLite syntax - executar_sql_via_url now applies the same timeout/read-only session settings the dialect-specific executors already had - schema fallback for an unsupported dialect (e.g. duckdb) now raises a clear error instead of silently trying to open it with the sqlite3 driver - schema introspection failures (native fallback and db_url path) now return a structured exec_erro instead of an uncaught exception - db_url schema path now applies FK inference the same way the db_config path already did - db_dialeto now defaults to "" instead of "sqlite" through runtime/ InsightEngine/cli, so extension-based detection (.duckdb etc) isn't short-circuited for callers who don't set it explicitly --- text_to_insight/InsightEngine.py | 2 +- text_to_insight/cli.py | 19 ++++-- .../nodes/code_agent/code_agent.py | 21 ++++++- text_to_insight/nodes/code_agent/code_sql.py | 59 +++++++++++++++++-- text_to_insight/nodes/schema.py | 51 ++++++++++++---- text_to_insight/runtime.py | 31 +++++++++- 6 files changed, 157 insertions(+), 26 deletions(-) diff --git a/text_to_insight/InsightEngine.py b/text_to_insight/InsightEngine.py index 4639620..11e42ff 100644 --- a/text_to_insight/InsightEngine.py +++ b/text_to_insight/InsightEngine.py @@ -31,7 +31,7 @@ def __init__( enrich_rag: bool = False, inferir_fks_virtuais: bool = False, usar_schemacrawler: bool = True, - db_dialeto: str = "sqlite", + db_dialeto: str = "", db_config: dict | None = None, db_url: str = "", ): diff --git a/text_to_insight/cli.py b/text_to_insight/cli.py index dacac2d..9f54873 100644 --- a/text_to_insight/cli.py +++ b/text_to_insight/cli.py @@ -60,8 +60,12 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument( "--db-dialeto", choices=["sqlite", "postgresql", "mysql"], - default="sqlite", - help="Dialeto do banco: sqlite, postgresql ou mysql. Padrão: sqlite.", + default="", + help=( + "Dialeto do banco: sqlite, postgresql ou mysql. Se omitido, e detectado " + "automaticamente pela extensao de --db-path (.duckdb, .sqlite, etc), " + "com sqlite como fallback." + ), ) parser.add_argument( "--db-host", @@ -86,8 +90,13 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: ) parser.add_argument( "--db-password", - default=None, - help="Senha do banco remoto.", + default=os.getenv("DB_PASSWORD"), + help=( + "Senha do banco remoto. EVITE usar esta flag diretamente: qualquer " + "argumento de linha de comando fica visivel no historico do shell e " + "na lista de processos do sistema. Prefira definir a variavel de " + "ambiente DB_PASSWORD (ex: no .env)." + ), ) parser.add_argument( "--db-url", @@ -165,7 +174,7 @@ def main(argv: list[str] | None = None) -> dict[str, Any]: "Configure a chave da API antes de executar." ) db_config = None - if args.db_dialeto != "sqlite": + if args.db_dialeto in ("postgresql", "mysql"): db_config = { "host": args.db_host, "port": args.db_port, diff --git a/text_to_insight/nodes/code_agent/code_agent.py b/text_to_insight/nodes/code_agent/code_agent.py index 7211bd0..39ffdb5 100644 --- a/text_to_insight/nodes/code_agent/code_agent.py +++ b/text_to_insight/nodes/code_agent/code_agent.py @@ -43,10 +43,25 @@ def _resolver_info_dialeto(estado: EstadoTextToInsight) -> tuple[str, str]: """Resolve (nome_exibicao, nota_sintaxe) do dialeto configurado no estado. - Default é sempre 'sqlite', preservando o comportamento anterior quando - `db_dialeto` não é informado (compatibilidade retroativa). + Ordem de resolução: + 1. `db_dialeto`, se informado explicitamente. + 2. `db_url`, cujo dialeto é lido do prefixo da URL (sem conectar). + 3. 'sqlite' como default, preservando o comportamento anterior quando + nada é informado (compatibilidade retroativa). """ - dialeto = (estado.get("db_dialeto") or "sqlite").strip().lower() + dialeto = (estado.get("db_dialeto") or "").strip().lower() + + if not dialeto: + db_url = (estado.get("db_url") or "").strip() + if db_url: + try: + from sqlalchemy.engine import make_url + + dialeto = make_url(db_url).get_backend_name() + except Exception: + dialeto = "" + + dialeto = dialeto or "sqlite" nome = _NOME_DIALETO.get(dialeto, _NOME_DIALETO["sqlite"]) nota = _NOTA_DIALETO.get(dialeto, _NOTA_DIALETO["sqlite"]) return nome, nota diff --git a/text_to_insight/nodes/code_agent/code_sql.py b/text_to_insight/nodes/code_agent/code_sql.py index 5aa674d..ae1ccc3 100644 --- a/text_to_insight/nodes/code_agent/code_sql.py +++ b/text_to_insight/nodes/code_agent/code_sql.py @@ -13,8 +13,24 @@ from typing import Any # Expressão regular para detectar comandos SQL potencialmente perigosos. +# +# Além dos comandos de escrita/DDL óbvios, bloqueia também construções que +# COMEÇAM com SELECT/WITH (logo passariam pela checagem de "apenas leitura") +# mas na prática escrevem ou leem arquivos no servidor de banco: +# - MySQL: "SELECT ... INTO OUTFILE/DUMPFILE" escreve um arquivo; +# LOAD_FILE() lê um arquivo arbitrário do disco. +# - PostgreSQL: pg_read_file/pg_read_binary_file/pg_ls_dir leem arquivos e +# diretórios do servidor; lo_import/lo_export leem/escrevem via +# large objects — todas exigem privilégio elevado, mas são +# bloqueadas aqui como defesa em profundidade. +# - SQLite: load_extension() carrega uma biblioteca nativa arbitrária. BLOQUEIOS_REGEX = re.compile( - r"\b(insert|update|delete|drop|alter|truncate|create|attach|detach|pragma|vacuum|reindex|replace)\b", + r"\b(" + r"insert|update|delete|drop|alter|truncate|create|attach|detach|pragma|" + r"vacuum|reindex|replace|" + r"into\s+outfile|into\s+dumpfile|load_file|load_extension|" + r"pg_read_file|pg_read_binary_file|pg_ls_dir|lo_import|lo_export" + r")\b", re.IGNORECASE, ) @@ -308,6 +324,20 @@ def executar_sql( return executar_sql_sqlite(db_path, sql, limite_preview, timeout_segundos) +def _normalizar_db_url(db_url: str) -> str: + """ + Garante que "postgresql://" e "mysql://" sem driver explicito usem os + drivers que este projeto de fato instala (psycopg2 e pymysql). Sem isso, + o SQLAlchemy tenta o driver padrao dele (ex: psycopg v3 para postgres), + que nao esta instalado aqui, e a conexao falha com "No module named ...". + """ + if db_url.startswith("postgresql://"): + return "postgresql+psycopg2://" + db_url[len("postgresql://"):] + if db_url.startswith("mysql://"): + return "mysql+pymysql://" + db_url[len("mysql://"):] + return db_url + + def executar_sql_via_url( db_url: str, sql: str, @@ -324,9 +354,15 @@ def executar_sql_via_url( if not ok: return _erro_execucao(f"SQL invalida: {erro_validacao}") + db_url = _normalizar_db_url(db_url) + from sqlalchemy import create_engine, text - engine = create_engine(db_url) + try: + engine = create_engine(db_url) + except Exception as e: + return _erro_execucao(f"URL de conexao invalida: {e}") + try: opcoes = {} if engine.dialect.name == "postgresql": @@ -335,13 +371,28 @@ def executar_sql_via_url( with engine.connect() as conn: conn = conn.execution_options(**opcoes) if opcoes else conn - if engine.dialect.name == "mysql": + # Aplica timeout e modo somente-leitura conforme o dialeto detectado. + if engine.dialect.name == "postgresql": + conn.execute(text(f"SET statement_timeout = {int(timeout_segundos * 1000)}")) + elif engine.dialect.name == "mysql": conn.execute(text("SET SESSION TRANSACTION READ ONLY")) + conn.execute(text(f"SET SESSION max_execution_time = {int(timeout_segundos * 1000)}")) + elif engine.dialect.name == "sqlite": + dbapi_conn = getattr(conn.connection, "dbapi_connection", conn.connection) + inicio = time.time() + + def _progress_handler(): + return 1 if time.time() - inicio > timeout_segundos else 0 + + dbapi_conn.set_progress_handler(_progress_handler, 1000) resultado = conn.execute(text(sql)) rows = [dict(linha) for linha in resultado.mappings().all()] except Exception as e: - return _erro_execucao(f"Falha ao executar SQL: {e}") + erro_msg = str(e) + if "interrupted" in erro_msg.lower() or "timeout" in erro_msg.lower() or "canceling statement" in erro_msg.lower(): + return _erro_execucao(f"Query abortada por timeout (> {timeout_segundos}s).") + return _erro_execucao(f"Falha ao executar SQL: {erro_msg}") finally: engine.dispose() diff --git a/text_to_insight/nodes/schema.py b/text_to_insight/nodes/schema.py index 1f6702f..ddba011 100644 --- a/text_to_insight/nodes/schema.py +++ b/text_to_insight/nodes/schema.py @@ -586,10 +586,19 @@ def _introspeccao_nativa(dialeto: str, caminho_db: Path | None, db_cfg: dict) -> finally: conn.close() - # sqlite / duckdb / fallback padrão - conn = sqlite3.connect(f"file:{caminho_db}?mode=ro", uri=True) - with conn: - return _formatar_schema_sqlite(conn) + if dialeto == "sqlite": + conn = sqlite3.connect(f"file:{caminho_db}?mode=ro", uri=True) + with conn: + return _formatar_schema_sqlite(conn) + + # duckdb e qualquer outro dialeto sem introspecção nativa: sem fallback + # seguro (abrir um arquivo .duckdb com o driver sqlite3 falha ou pode dar + # um resultado incorreto silenciosamente). + raise RuntimeError( + f"Dialeto '{dialeto}' não possui introspecção nativa disponível " + "(apenas sqlite, postgresql e mysql). Configure schemacrawler_bin " + "para este dialeto." + ) def _formatar_schema_sqlalchemy(engine) -> str: from sqlalchemy import inspect @@ -808,11 +817,23 @@ def nos_nodo_esquema(estado: EstadoTextToInsight) -> dict: if db_url: from sqlalchemy import create_engine - engine = create_engine(db_url) + from .code_agent.code_sql import _normalizar_db_url + try: - contexto = _formatar_schema_sqlalchemy(engine) - finally: - engine.dispose() + engine = create_engine(_normalizar_db_url(db_url)) + try: + contexto = _formatar_schema_sqlalchemy(engine) + finally: + engine.dispose() + except Exception as e: + msg = f"Falha ao ler schema via db_url: {e}" + print(f"[SCHEMA] Erro: {msg}") + return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} + + if estado.get("inferir_fks_virtuais", False): + print("[SCHEMA] Inferindo FKs virtuais.") + contexto = _inferir_fks_virtuais(contexto) + return { "contexto_schema": contexto, "erro_execucao": "", @@ -873,13 +894,23 @@ def nos_nodo_esquema(estado: EstadoTextToInsight) -> dict: print("[SCHEMA] Schema Crawler: introspecção concluída.") except Exception as e: print(f"[SCHEMA] Schema Crawler falhou ({e}). Tentando fallback nativo...") - contexto = _introspeccao_nativa(dialeto, caminho_db, db_cfg) + try: + contexto = _introspeccao_nativa(dialeto, caminho_db, db_cfg) + except Exception as e2: + msg = f"Falha na introspecção nativa (dialeto '{dialeto}'): {e2}" + print(f"[SCHEMA] Erro: {msg}") + return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} else: if not usar_schemacrawler: print(f"[SCHEMA] usar_schemacrawler desativado — usando introspecção nativa ({dialeto}).") else: print(f"[SCHEMA] schemacrawler_bin não configurado — usando introspecção nativa ({dialeto}).") - contexto = _introspeccao_nativa(dialeto, caminho_db, db_cfg) + try: + contexto = _introspeccao_nativa(dialeto, caminho_db, db_cfg) + except Exception as e: + msg = f"Falha na introspecção nativa (dialeto '{dialeto}'): {e}" + print(f"[SCHEMA] Erro: {msg}") + return {"contexto_schema": "", "erro_execucao": msg, "status": "exec_erro"} # --- Heurística de FKs virtuais --- if inferir_fks_virtuais: diff --git a/text_to_insight/runtime.py b/text_to_insight/runtime.py index 05d46d1..c33f604 100644 --- a/text_to_insight/runtime.py +++ b/text_to_insight/runtime.py @@ -46,7 +46,7 @@ def construir_estado_inicial( db_path: str, inferir_fks_virtuais: bool = False, usar_schemacrawler: bool = True, - db_dialeto: str = "sqlite", + db_dialeto: str = "", db_config: dict[str, Any] | None = None, db_url: str = "", ) -> dict[str, Any]: @@ -76,6 +76,31 @@ def construir_estado_inicial( } +_URL_SENHA_REGEX = re.compile(r"(://[^:/@\s]+:)[^@\s]+(@)") + + +def _redigir_credenciais(estado: dict[str, Any]) -> dict[str, Any]: + """ + Devolve uma copia rasa do estado com senha de `db_config` e `db_url` + mascaradas. Usar sempre antes de devolver o estado final ao chamador + (biblioteca) ou de persisti-lo fora do checkpointer do grafo — o estado + bruto guarda a senha em texto puro, e nao deve vazar pra fora do grafo. + """ + estado_seguro = dict(estado) + + db_config = estado_seguro.get("db_config") + if isinstance(db_config, dict) and db_config.get("password"): + db_config_seguro = dict(db_config) + db_config_seguro["password"] = "***" + estado_seguro["db_config"] = db_config_seguro + + db_url = estado_seguro.get("db_url") + if db_url: + estado_seguro["db_url"] = _URL_SENHA_REGEX.sub(r"\1***\2", db_url) + + return estado_seguro + + def _montar_saida_resultado_terminal(resultado: dict[str, Any]) -> str: """Monta o texto do bloco de resultado para exibição no terminal.""" linhas = resultado.get("linhas_resultado_completo", []) or [] @@ -406,7 +431,7 @@ def _resultado_aguardando_usuario(snapshot_values: dict[str, Any], thread_id: st def _resultado_hitl_bloqueado(snapshot_values: dict[str, Any]) -> dict[str, Any]: - resultado_final = dict(snapshot_values) + resultado_final = _redigir_credenciais(snapshot_values) resultado_final.update( { "status": HITL_BLOCKED_STATUS, @@ -484,7 +509,7 @@ def executar_fluxo( snapshot = grafo_app.get_state(config) if not snapshot.next: - resultado_final = snapshot.values + resultado_final = _redigir_credenciais(snapshot.values) salvar_metricas_csv(resultado_final, time.perf_counter() - lat_inicio) return resultado_final