Deploy
Browse files- .gitignore +170 -0
- agents/processing_agent.py +242 -0
- agents/sql_agent.py +392 -0
- agents/tools.py +509 -0
- app.py +487 -0
- graphs/main_graph.py +827 -0
- nodes/agent_node.py +136 -0
- nodes/cache_node.py +230 -0
- nodes/csv_processing_node.py +815 -0
- nodes/custom_nodes.py +297 -0
- nodes/database_node.py +226 -0
- nodes/graph_generation_node.py +1015 -0
- nodes/graph_selection_node.py +147 -0
- nodes/processing_node.py +230 -0
- nodes/query_node.py +247 -0
- nodes/refinement_node.py +141 -0
- requirements.txt +28 -0
- tabela.csv +0 -3
- utils/config.py +180 -0
- utils/database.py +93 -0
- utils/object_manager.py +159 -0
.gitignore
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Banco de dados
|
| 2 |
+
data.db
|
| 3 |
+
*.db
|
| 4 |
+
*.sqlite
|
| 5 |
+
*.sqlite3
|
| 6 |
+
|
| 7 |
+
# Arquivos CSV de upload
|
| 8 |
+
uploaded_data.csv
|
| 9 |
+
test_upload.csv
|
| 10 |
+
tabela.csv
|
| 11 |
+
|
| 12 |
+
# Logs
|
| 13 |
+
*.log
|
| 14 |
+
logs/
|
| 15 |
+
__pycache__/
|
| 16 |
+
*.py[cod]
|
| 17 |
+
*$py.class
|
| 18 |
+
|
| 19 |
+
# Distribuição / empacotamento
|
| 20 |
+
*.so
|
| 21 |
+
.Python
|
| 22 |
+
build/
|
| 23 |
+
develop-eggs/
|
| 24 |
+
dist/
|
| 25 |
+
downloads/
|
| 26 |
+
eggs/
|
| 27 |
+
.eggs/
|
| 28 |
+
lib/
|
| 29 |
+
lib64/
|
| 30 |
+
parts/
|
| 31 |
+
sdist/
|
| 32 |
+
var/
|
| 33 |
+
wheels/
|
| 34 |
+
pip-wheel-metadata/
|
| 35 |
+
share/python-wheels/
|
| 36 |
+
*.egg-info/
|
| 37 |
+
.installed.cfg
|
| 38 |
+
*.egg
|
| 39 |
+
MANIFEST
|
| 40 |
+
|
| 41 |
+
# PyInstaller
|
| 42 |
+
*.manifest
|
| 43 |
+
*.spec
|
| 44 |
+
|
| 45 |
+
# Installer logs
|
| 46 |
+
pip-log.txt
|
| 47 |
+
pip-delete-this-directory.txt
|
| 48 |
+
|
| 49 |
+
# Unit test / coverage reports
|
| 50 |
+
htmlcov/
|
| 51 |
+
.tox/
|
| 52 |
+
.nox/
|
| 53 |
+
.coverage
|
| 54 |
+
.coverage.*
|
| 55 |
+
.cache
|
| 56 |
+
nosetests.xml
|
| 57 |
+
coverage.xml
|
| 58 |
+
*.cover
|
| 59 |
+
*.py,cover
|
| 60 |
+
.hypothesis/
|
| 61 |
+
.pytest_cache/
|
| 62 |
+
|
| 63 |
+
# Translations
|
| 64 |
+
*.mo
|
| 65 |
+
*.pot
|
| 66 |
+
|
| 67 |
+
# Django stuff:
|
| 68 |
+
*.log
|
| 69 |
+
local_settings.py
|
| 70 |
+
db.sqlite3
|
| 71 |
+
db.sqlite3-journal
|
| 72 |
+
|
| 73 |
+
# Flask stuff:
|
| 74 |
+
instance/
|
| 75 |
+
.webassets-cache
|
| 76 |
+
|
| 77 |
+
# Scrapy stuff:
|
| 78 |
+
.scrapy
|
| 79 |
+
|
| 80 |
+
# Sphinx documentation
|
| 81 |
+
docs/_build/
|
| 82 |
+
|
| 83 |
+
# PyBuilder
|
| 84 |
+
target/
|
| 85 |
+
|
| 86 |
+
# Jupyter Notebook
|
| 87 |
+
.ipynb_checkpoints
|
| 88 |
+
|
| 89 |
+
# IPython
|
| 90 |
+
profile_default/
|
| 91 |
+
ipython_config.py
|
| 92 |
+
|
| 93 |
+
# pyenv
|
| 94 |
+
.python-version
|
| 95 |
+
|
| 96 |
+
# pipenv
|
| 97 |
+
Pipfile.lock
|
| 98 |
+
|
| 99 |
+
# PEP 582
|
| 100 |
+
__pypackages__/
|
| 101 |
+
|
| 102 |
+
# Celery stuff
|
| 103 |
+
celerybeat-schedule
|
| 104 |
+
celerybeat.pid
|
| 105 |
+
|
| 106 |
+
# SageMath parsed files
|
| 107 |
+
*.sage.py
|
| 108 |
+
|
| 109 |
+
# Environments
|
| 110 |
+
.env
|
| 111 |
+
.venv
|
| 112 |
+
env/
|
| 113 |
+
venv/
|
| 114 |
+
ENV/
|
| 115 |
+
env.bak/
|
| 116 |
+
venv.bak/
|
| 117 |
+
|
| 118 |
+
# Spyder project settings
|
| 119 |
+
.spyderproject
|
| 120 |
+
.spyproject
|
| 121 |
+
|
| 122 |
+
# Rope project settings
|
| 123 |
+
.ropeproject
|
| 124 |
+
|
| 125 |
+
# mkdocs documentation
|
| 126 |
+
/site
|
| 127 |
+
|
| 128 |
+
# mypy
|
| 129 |
+
.mypy_cache/
|
| 130 |
+
.dmypy.json
|
| 131 |
+
dmypy.json
|
| 132 |
+
|
| 133 |
+
# Pyre type checker
|
| 134 |
+
.pyre/
|
| 135 |
+
|
| 136 |
+
# IDEs
|
| 137 |
+
.vscode/
|
| 138 |
+
.idea/
|
| 139 |
+
*.swp
|
| 140 |
+
*.swo
|
| 141 |
+
*~
|
| 142 |
+
|
| 143 |
+
# OS
|
| 144 |
+
.DS_Store
|
| 145 |
+
.DS_Store?
|
| 146 |
+
._*
|
| 147 |
+
.Spotlight-V100
|
| 148 |
+
.Trashes
|
| 149 |
+
ehthumbs.db
|
| 150 |
+
Thumbs.db
|
| 151 |
+
|
| 152 |
+
# Temporary files
|
| 153 |
+
*.tmp
|
| 154 |
+
*.temp
|
| 155 |
+
temp/
|
| 156 |
+
tmp/
|
| 157 |
+
|
| 158 |
+
# API Keys e configurações sensíveis
|
| 159 |
+
.env.local
|
| 160 |
+
.env.production
|
| 161 |
+
config.json
|
| 162 |
+
secrets.json
|
| 163 |
+
|
| 164 |
+
# Gradio
|
| 165 |
+
gradio_cached_examples/
|
| 166 |
+
flagged/
|
| 167 |
+
|
| 168 |
+
# Cache do sistema
|
| 169 |
+
cache/
|
| 170 |
+
*.cache
|
agents/processing_agent.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agente de processamento de contexto inicial para sugestão de queries SQL
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
import asyncio
|
| 6 |
+
from typing import Optional, Dict, Any
|
| 7 |
+
from langchain_openai import ChatOpenAI
|
| 8 |
+
from langchain_anthropic import ChatAnthropic
|
| 9 |
+
from langchain_community.llms import HuggingFaceEndpoint
|
| 10 |
+
from langchain.schema import HumanMessage
|
| 11 |
+
|
| 12 |
+
from utils.config import (
|
| 13 |
+
TEMPERATURE,
|
| 14 |
+
AVAILABLE_MODELS,
|
| 15 |
+
OPENAI_MODELS,
|
| 16 |
+
ANTHROPIC_MODELS,
|
| 17 |
+
REFINEMENT_MODELS
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ProcessingAgentManager:
|
| 22 |
+
"""
|
| 23 |
+
Gerenciador do agente de processamento de contexto inicial
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, model_name: str = "gpt-4o-mini"):
|
| 27 |
+
self.model_name = model_name
|
| 28 |
+
self.llm = None
|
| 29 |
+
self._initialize_llm()
|
| 30 |
+
|
| 31 |
+
def _initialize_llm(self):
|
| 32 |
+
"""Inicializa o modelo LLM baseado no nome fornecido"""
|
| 33 |
+
try:
|
| 34 |
+
# Obtém o ID real do modelo
|
| 35 |
+
model_id = AVAILABLE_MODELS.get(self.model_name, self.model_name)
|
| 36 |
+
|
| 37 |
+
# Verifica se é modelo de refinamento
|
| 38 |
+
if model_id not in AVAILABLE_MODELS.values():
|
| 39 |
+
model_id = REFINEMENT_MODELS.get(self.model_name, model_id)
|
| 40 |
+
|
| 41 |
+
# Cria o modelo LLM baseado no provedor
|
| 42 |
+
if model_id in OPENAI_MODELS:
|
| 43 |
+
# Configurações específicas para modelos OpenAI
|
| 44 |
+
if model_id == "o3-mini":
|
| 45 |
+
# o3-mini não suporta temperature
|
| 46 |
+
self.llm = ChatOpenAI(model=model_id)
|
| 47 |
+
else:
|
| 48 |
+
# GPT-4o e GPT-4o-mini suportam temperature
|
| 49 |
+
self.llm = ChatOpenAI(model=model_id, temperature=TEMPERATURE)
|
| 50 |
+
|
| 51 |
+
elif model_id in ANTHROPIC_MODELS:
|
| 52 |
+
# Claude com tool-calling e configurações para rate limiting
|
| 53 |
+
self.llm = ChatAnthropic(
|
| 54 |
+
model=model_id,
|
| 55 |
+
temperature=TEMPERATURE,
|
| 56 |
+
max_tokens=4096,
|
| 57 |
+
max_retries=2,
|
| 58 |
+
timeout=60.0
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
else:
|
| 62 |
+
# Modelos HuggingFace (refinement models)
|
| 63 |
+
self.llm = HuggingFaceEndpoint(
|
| 64 |
+
endpoint_url=f"https://api-inference.huggingface.co/models/{model_id}",
|
| 65 |
+
temperature=TEMPERATURE,
|
| 66 |
+
max_new_tokens=1024,
|
| 67 |
+
timeout=120
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
logging.info(f"Processing Agent inicializado com modelo {model_id}")
|
| 71 |
+
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logging.error(f"Erro ao inicializar Processing Agent: {e}")
|
| 74 |
+
# Fallback para GPT-4o-mini
|
| 75 |
+
self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=TEMPERATURE)
|
| 76 |
+
logging.warning("Usando GPT-4o-mini como fallback")
|
| 77 |
+
|
| 78 |
+
def recreate_llm(self, new_model: str):
|
| 79 |
+
"""
|
| 80 |
+
Recria o LLM com novo modelo
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
new_model: Nome do novo modelo
|
| 84 |
+
"""
|
| 85 |
+
old_model = self.model_name
|
| 86 |
+
self.model_name = new_model
|
| 87 |
+
self._initialize_llm()
|
| 88 |
+
logging.info(f"[PROCESSING] Modelo alterado de '{old_model}' para '{new_model}'")
|
| 89 |
+
|
| 90 |
+
async def process_context(self, context_prompt: str) -> Dict[str, Any]:
|
| 91 |
+
"""
|
| 92 |
+
Processa o contexto inicial e retorna sugestão de query
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
context_prompt: Prompt com contexto e pergunta do usuário
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
Resultado do processamento com pergunta e sugestão de query
|
| 99 |
+
"""
|
| 100 |
+
try:
|
| 101 |
+
logging.info(f"[PROCESSING] ===== INICIANDO PROCESSING AGENT =====")
|
| 102 |
+
logging.info(f"[PROCESSING] Modelo utilizado: {self.model_name}")
|
| 103 |
+
logging.info(f"[PROCESSING] Tamanho do contexto: {len(context_prompt)} caracteres")
|
| 104 |
+
|
| 105 |
+
# Executa o processamento
|
| 106 |
+
if hasattr(self.llm, 'ainvoke'):
|
| 107 |
+
# Para modelos que suportam async
|
| 108 |
+
logging.info(f"[PROCESSING] Executando chamada assíncrona para {self.model_name}")
|
| 109 |
+
response = await self.llm.ainvoke([HumanMessage(content=context_prompt)])
|
| 110 |
+
output = response.content
|
| 111 |
+
else:
|
| 112 |
+
# Para modelos síncronos, executa em thread
|
| 113 |
+
logging.info(f"[PROCESSING] Executando chamada síncrona para {self.model_name}")
|
| 114 |
+
response = await asyncio.get_event_loop().run_in_executor(
|
| 115 |
+
None,
|
| 116 |
+
lambda: self.llm.invoke([HumanMessage(content=context_prompt)])
|
| 117 |
+
)
|
| 118 |
+
output = response.content if hasattr(response, 'content') else str(response)
|
| 119 |
+
|
| 120 |
+
logging.info(f"[PROCESSING] Resposta recebida do modelo ({len(output)} caracteres)")
|
| 121 |
+
|
| 122 |
+
# Processa a resposta
|
| 123 |
+
processed_result = self._parse_processing_response(output)
|
| 124 |
+
|
| 125 |
+
result = {
|
| 126 |
+
"success": True,
|
| 127 |
+
"output": output,
|
| 128 |
+
"processed_question": processed_result.get("question", ""),
|
| 129 |
+
"suggested_query": processed_result.get("query", ""),
|
| 130 |
+
"query_observations": processed_result.get("observations", ""),
|
| 131 |
+
"model_used": self.model_name
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
# Log simples do resultado
|
| 135 |
+
if result['suggested_query']:
|
| 136 |
+
logging.info(f"[PROCESSING] ✅ Query SQL extraída com sucesso")
|
| 137 |
+
else:
|
| 138 |
+
logging.warning(f"[PROCESSING] ❌ Nenhuma query SQL foi extraída")
|
| 139 |
+
|
| 140 |
+
logging.info(f"[PROCESSING] ===== PROCESSING AGENT CONCLUÍDO =====")
|
| 141 |
+
return result
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
error_msg = f"Erro no Processing Agent: {e}"
|
| 145 |
+
logging.error(error_msg)
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"success": False,
|
| 149 |
+
"output": error_msg,
|
| 150 |
+
"processed_question": "",
|
| 151 |
+
"suggested_query": "",
|
| 152 |
+
"model_used": self.model_name
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
def _parse_processing_response(self, response: str) -> Dict[str, str]:
|
| 156 |
+
"""
|
| 157 |
+
Extrai query SQL e observações da resposta
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
response: Resposta do modelo
|
| 161 |
+
|
| 162 |
+
Returns:
|
| 163 |
+
Dicionário com query e observações extraídas
|
| 164 |
+
"""
|
| 165 |
+
try:
|
| 166 |
+
import re
|
| 167 |
+
|
| 168 |
+
query = ""
|
| 169 |
+
observations = ""
|
| 170 |
+
|
| 171 |
+
# Primeiro, tenta extrair observações pelo formato esperado
|
| 172 |
+
obs_match = re.search(r'Observações:\s*(.*?)(?:\n|$)', response, re.IGNORECASE)
|
| 173 |
+
if obs_match:
|
| 174 |
+
observations = obs_match.group(1).strip()
|
| 175 |
+
|
| 176 |
+
# Agora extrai a query SQL - prioriza blocos de código SQL
|
| 177 |
+
sql_patterns = [
|
| 178 |
+
# Padrão principal: ```sql ... ```
|
| 179 |
+
r'```sql\s*(.*?)\s*```',
|
| 180 |
+
# Padrão alternativo: ``` ... ``` (assumindo que é SQL)
|
| 181 |
+
r'```\s*(WITH.*?)\s*```',
|
| 182 |
+
r'```\s*(SELECT.*?)\s*```',
|
| 183 |
+
# Padrões sem backticks
|
| 184 |
+
r'Opção de querySQL:\s*(WITH.*?)(?=Observações:|$)',
|
| 185 |
+
r'Opção de querySQL:\s*(SELECT.*?)(?=Observações:|$)',
|
| 186 |
+
# Padrões mais gerais
|
| 187 |
+
r'(WITH\s+.*?;)',
|
| 188 |
+
r'(SELECT\s+.*?;)'
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
for pattern in sql_patterns:
|
| 192 |
+
match = re.search(pattern, response, re.DOTALL | re.IGNORECASE)
|
| 193 |
+
if match:
|
| 194 |
+
query = match.group(1).strip()
|
| 195 |
+
break
|
| 196 |
+
|
| 197 |
+
# Limpa a query final se encontrada
|
| 198 |
+
if query:
|
| 199 |
+
# Remove apenas backticks e mantém formatação original
|
| 200 |
+
query = query.replace('```', '').replace('sql', '').strip()
|
| 201 |
+
|
| 202 |
+
# Remove quebras de linha no início e fim, mas mantém formatação interna
|
| 203 |
+
query = query.strip('\n').strip()
|
| 204 |
+
|
| 205 |
+
# Se ainda não encontrou observações, tenta padrão mais flexível
|
| 206 |
+
if not observations:
|
| 207 |
+
obs_patterns = [
|
| 208 |
+
r'Observações:\s*(.*)',
|
| 209 |
+
r'Observacoes:\s*(.*)',
|
| 210 |
+
]
|
| 211 |
+
for pattern in obs_patterns:
|
| 212 |
+
match = re.search(pattern, response, re.IGNORECASE | re.DOTALL)
|
| 213 |
+
if match:
|
| 214 |
+
observations = match.group(1).strip()
|
| 215 |
+
break
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
"question": "", # Não precisamos da pergunta processada
|
| 219 |
+
"query": query,
|
| 220 |
+
"observations": observations
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logging.error(f"Erro ao extrair query e observações: {e}")
|
| 225 |
+
return {
|
| 226 |
+
"question": "",
|
| 227 |
+
"query": "",
|
| 228 |
+
"observations": ""
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def get_default_processing_agent(model_name: str = "gpt-4o-mini") -> ProcessingAgentManager:
|
| 233 |
+
"""
|
| 234 |
+
Cria um Processing Agent com configurações padrão
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
model_name: Nome do modelo a usar
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
ProcessingAgentManager configurado
|
| 241 |
+
"""
|
| 242 |
+
return ProcessingAgentManager(model_name)
|
agents/sql_agent.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Criação e configuração do agente SQL
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
import time
|
| 6 |
+
import asyncio
|
| 7 |
+
from typing import Optional, Dict, Any, List
|
| 8 |
+
from langchain_openai import ChatOpenAI
|
| 9 |
+
from langchain_anthropic import ChatAnthropic
|
| 10 |
+
from langchain_community.agent_toolkits import create_sql_agent
|
| 11 |
+
from langchain_community.utilities import SQLDatabase
|
| 12 |
+
from langchain.callbacks.base import BaseCallbackHandler
|
| 13 |
+
from langchain.schema import AgentAction, AgentFinish
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
from utils.config import (
|
| 17 |
+
MAX_ITERATIONS,
|
| 18 |
+
TEMPERATURE,
|
| 19 |
+
AVAILABLE_MODELS,
|
| 20 |
+
OPENAI_MODELS,
|
| 21 |
+
ANTHROPIC_MODELS
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
class SQLQueryCaptureHandler(BaseCallbackHandler):
|
| 25 |
+
"""
|
| 26 |
+
Handler para capturar queries SQL executadas pelo agente
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
super().__init__()
|
| 31 |
+
self.sql_queries: List[str] = []
|
| 32 |
+
self.agent_actions: List[Dict[str, Any]] = []
|
| 33 |
+
self.step_count = 0
|
| 34 |
+
|
| 35 |
+
def on_agent_action(self, action: AgentAction, **kwargs) -> None:
|
| 36 |
+
"""
|
| 37 |
+
Captura ações do agente, especialmente queries SQL
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
action: Ação do agente
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
self.step_count += 1
|
| 44 |
+
tool_name = action.tool
|
| 45 |
+
tool_input = action.tool_input
|
| 46 |
+
|
| 47 |
+
# Capturar SQL especificamente (sem log de cada passo)
|
| 48 |
+
if tool_name == 'sql_db_query' and isinstance(tool_input, dict):
|
| 49 |
+
sql_query = tool_input.get('query', '')
|
| 50 |
+
if sql_query and sql_query.strip():
|
| 51 |
+
clean_query = sql_query.strip()
|
| 52 |
+
self.sql_queries.append(clean_query)
|
| 53 |
+
|
| 54 |
+
# Log apenas uma vez com query completa
|
| 55 |
+
logging.info(f"[SQL_HANDLER] 🔍 Query SQL capturada:\n{clean_query}")
|
| 56 |
+
|
| 57 |
+
# Armazenar todas as ações para debug
|
| 58 |
+
self.agent_actions.append({
|
| 59 |
+
"step": self.step_count,
|
| 60 |
+
"tool": tool_name,
|
| 61 |
+
"input": tool_input,
|
| 62 |
+
"timestamp": time.time()
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logging.error(f"[SQL_HANDLER] Erro ao capturar ação: {e}")
|
| 67 |
+
|
| 68 |
+
def get_last_sql_query(self) -> Optional[str]:
|
| 69 |
+
"""
|
| 70 |
+
Retorna a última query SQL capturada
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
Última query SQL ou None se não houver
|
| 74 |
+
"""
|
| 75 |
+
return self.sql_queries[-1] if self.sql_queries else None
|
| 76 |
+
|
| 77 |
+
def get_all_sql_queries(self) -> List[str]:
|
| 78 |
+
"""
|
| 79 |
+
Retorna todas as queries SQL capturadas
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Lista de queries SQL
|
| 83 |
+
"""
|
| 84 |
+
return self.sql_queries.copy()
|
| 85 |
+
|
| 86 |
+
def reset(self):
|
| 87 |
+
"""Reseta o handler para nova execução"""
|
| 88 |
+
self.sql_queries.clear()
|
| 89 |
+
self.agent_actions.clear()
|
| 90 |
+
self.step_count = 0
|
| 91 |
+
|
| 92 |
+
async def retry_with_backoff(func, max_retries=3, base_delay=1.0):
|
| 93 |
+
"""
|
| 94 |
+
Executa função com retry e backoff exponencial para lidar com rate limiting
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
func: Função a ser executada
|
| 98 |
+
max_retries: Número máximo de tentativas
|
| 99 |
+
base_delay: Delay base em segundos
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Resultado da função ou levanta exceção após esgotar tentativas
|
| 103 |
+
"""
|
| 104 |
+
for attempt in range(max_retries + 1):
|
| 105 |
+
try:
|
| 106 |
+
return func()
|
| 107 |
+
except Exception as e:
|
| 108 |
+
error_str = str(e)
|
| 109 |
+
|
| 110 |
+
# Verifica se é erro de rate limiting ou overload
|
| 111 |
+
if any(keyword in error_str.lower() for keyword in ['overloaded', 'rate_limit', 'too_many_requests', 'quota']):
|
| 112 |
+
if attempt < max_retries:
|
| 113 |
+
delay = base_delay * (2 ** attempt) # Backoff exponencial
|
| 114 |
+
logging.warning(f"API sobrecarregada (tentativa {attempt + 1}/{max_retries + 1}). Aguardando {delay}s...")
|
| 115 |
+
await asyncio.sleep(delay)
|
| 116 |
+
continue
|
| 117 |
+
else:
|
| 118 |
+
logging.error(f"API continua sobrecarregada após {max_retries + 1} tentativas")
|
| 119 |
+
raise Exception(f"API da Anthropic sobrecarregada. Tente novamente em alguns minutos. Erro original: {e}")
|
| 120 |
+
else:
|
| 121 |
+
# Se não é erro de rate limiting, levanta imediatamente
|
| 122 |
+
raise e
|
| 123 |
+
|
| 124 |
+
# Não deveria chegar aqui, mas por segurança
|
| 125 |
+
raise Exception("Número máximo de tentativas excedido")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def create_sql_agent_executor(db: SQLDatabase, model_name: str = "gpt-4o-mini"):
|
| 130 |
+
"""
|
| 131 |
+
Cria um agente SQL usando LangChain com suporte a diferentes provedores
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
db: Objeto SQLDatabase do LangChain
|
| 135 |
+
model_name: Nome do modelo a usar (OpenAI, Anthropic)
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Agente SQL configurado
|
| 139 |
+
"""
|
| 140 |
+
try:
|
| 141 |
+
# Obtém o ID real do modelo
|
| 142 |
+
model_id = AVAILABLE_MODELS.get(model_name, model_name)
|
| 143 |
+
|
| 144 |
+
# Cria o modelo LLM baseado no provedor
|
| 145 |
+
if model_id in OPENAI_MODELS:
|
| 146 |
+
# Configurações específicas para modelos OpenAI
|
| 147 |
+
if model_id == "o3-mini":
|
| 148 |
+
# o3-mini não suporta temperature
|
| 149 |
+
llm = ChatOpenAI(model=model_id)
|
| 150 |
+
else:
|
| 151 |
+
# GPT-4o e GPT-4o-mini suportam temperature
|
| 152 |
+
llm = ChatOpenAI(model=model_id, temperature=TEMPERATURE)
|
| 153 |
+
|
| 154 |
+
agent_type = "openai-tools"
|
| 155 |
+
|
| 156 |
+
elif model_id in ANTHROPIC_MODELS:
|
| 157 |
+
# Claude com tool-calling e configurações para rate limiting
|
| 158 |
+
llm = ChatAnthropic(
|
| 159 |
+
model=model_id,
|
| 160 |
+
temperature=TEMPERATURE,
|
| 161 |
+
max_tokens=4096,
|
| 162 |
+
max_retries=2, # Retry interno do cliente
|
| 163 |
+
timeout=60.0 # Timeout mais longo
|
| 164 |
+
)
|
| 165 |
+
agent_type = "tool-calling" # Claude usa tool-calling
|
| 166 |
+
|
| 167 |
+
else:
|
| 168 |
+
# Fallback para OpenAI
|
| 169 |
+
llm = ChatOpenAI(
|
| 170 |
+
model="gpt-4o-mini",
|
| 171 |
+
temperature=TEMPERATURE
|
| 172 |
+
)
|
| 173 |
+
agent_type = "openai-tools"
|
| 174 |
+
logging.warning(f"Modelo {model_name} não reconhecido, usando gpt-4o-mini como fallback")
|
| 175 |
+
|
| 176 |
+
# Cria o agente SQL
|
| 177 |
+
sql_agent = create_sql_agent(
|
| 178 |
+
llm=llm,
|
| 179 |
+
db=db,
|
| 180 |
+
agent_type=agent_type,
|
| 181 |
+
verbose=True,
|
| 182 |
+
max_iterations=MAX_ITERATIONS,
|
| 183 |
+
return_intermediate_steps=True,
|
| 184 |
+
top_k=10
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
logging.info(f"Agente SQL criado com sucesso usando modelo {model_name} ({model_id}) com agent_type={agent_type}")
|
| 188 |
+
return sql_agent
|
| 189 |
+
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logging.error(f"Erro ao criar agente SQL: {e}")
|
| 192 |
+
raise
|
| 193 |
+
|
| 194 |
+
class SQLAgentManager:
|
| 195 |
+
"""
|
| 196 |
+
Gerenciador do agente SQL com funcionalidades avançadas
|
| 197 |
+
"""
|
| 198 |
+
|
| 199 |
+
def __init__(self, db: SQLDatabase, model_name: str = "gpt-4o-mini"):
|
| 200 |
+
self.db = db
|
| 201 |
+
self.model_name = model_name
|
| 202 |
+
self.agent = None
|
| 203 |
+
self._initialize_agent()
|
| 204 |
+
|
| 205 |
+
def _initialize_agent(self):
|
| 206 |
+
"""Inicializa o agente SQL"""
|
| 207 |
+
self.agent = create_sql_agent_executor(self.db, self.model_name)
|
| 208 |
+
|
| 209 |
+
def recreate_agent(self, new_db: SQLDatabase = None, new_model: str = None):
|
| 210 |
+
"""
|
| 211 |
+
Recria o agente com novos parâmetros
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
new_db: Novo banco de dados (opcional)
|
| 215 |
+
new_model: Novo modelo (opcional)
|
| 216 |
+
"""
|
| 217 |
+
if new_db:
|
| 218 |
+
self.db = new_db
|
| 219 |
+
if new_model:
|
| 220 |
+
self.model_name = new_model
|
| 221 |
+
|
| 222 |
+
self._initialize_agent()
|
| 223 |
+
logging.info("Agente SQL recriado com sucesso")
|
| 224 |
+
|
| 225 |
+
def _extract_text_from_claude_response(self, output) -> str:
|
| 226 |
+
"""
|
| 227 |
+
Extrai texto limpo da resposta do Claude que pode vir em formato complexo
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
output: Resposta do agente (pode ser string, lista ou dict)
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
String limpa com o texto da resposta
|
| 234 |
+
"""
|
| 235 |
+
try:
|
| 236 |
+
# Se já é string, retorna diretamente
|
| 237 |
+
if isinstance(output, str):
|
| 238 |
+
return output
|
| 239 |
+
|
| 240 |
+
# Se é lista, procura por dicionários com 'text'
|
| 241 |
+
if isinstance(output, list):
|
| 242 |
+
text_parts = []
|
| 243 |
+
for item in output:
|
| 244 |
+
if isinstance(item, dict) and 'text' in item:
|
| 245 |
+
text_parts.append(item['text'])
|
| 246 |
+
elif isinstance(item, str):
|
| 247 |
+
text_parts.append(item)
|
| 248 |
+
|
| 249 |
+
if text_parts:
|
| 250 |
+
return '\n'.join(text_parts)
|
| 251 |
+
|
| 252 |
+
# Se é dict, procura por 'text' ou converte para string
|
| 253 |
+
if isinstance(output, dict):
|
| 254 |
+
if 'text' in output:
|
| 255 |
+
return output['text']
|
| 256 |
+
elif 'content' in output:
|
| 257 |
+
return str(output['content'])
|
| 258 |
+
|
| 259 |
+
# Fallback: converte para string
|
| 260 |
+
return str(output)
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logging.warning(f"Erro ao extrair texto da resposta: {e}")
|
| 264 |
+
return str(output)
|
| 265 |
+
|
| 266 |
+
async def execute_query(self, instruction: str) -> dict:
|
| 267 |
+
"""
|
| 268 |
+
Executa uma query através do agente SQL com retry para rate limiting
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
instruction: Instrução para o agente
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
Resultado da execução
|
| 275 |
+
"""
|
| 276 |
+
try:
|
| 277 |
+
logging.info("------- Agent SQL: Executando query -------")
|
| 278 |
+
|
| 279 |
+
# Criar handler para capturar SQL
|
| 280 |
+
sql_handler = SQLQueryCaptureHandler()
|
| 281 |
+
|
| 282 |
+
# Verifica se é agente Claude para aplicar retry
|
| 283 |
+
model_id = getattr(self, 'model_name', '')
|
| 284 |
+
is_claude = any(claude_model in model_id for claude_model in ANTHROPIC_MODELS)
|
| 285 |
+
|
| 286 |
+
if is_claude:
|
| 287 |
+
# Usa retry com backoff para Claude
|
| 288 |
+
response = await retry_with_backoff(
|
| 289 |
+
lambda: self.agent.invoke(
|
| 290 |
+
{"input": instruction},
|
| 291 |
+
{"callbacks": [sql_handler]}
|
| 292 |
+
),
|
| 293 |
+
max_retries=3,
|
| 294 |
+
base_delay=2.0
|
| 295 |
+
)
|
| 296 |
+
else:
|
| 297 |
+
# Execução normal para outros modelos
|
| 298 |
+
response = self.agent.invoke(
|
| 299 |
+
{"input": instruction},
|
| 300 |
+
{"callbacks": [sql_handler]}
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
# Extrai e limpa a resposta
|
| 304 |
+
raw_output = response.get("output", "Erro ao obter a resposta do agente.")
|
| 305 |
+
clean_output = self._extract_text_from_claude_response(raw_output)
|
| 306 |
+
|
| 307 |
+
# Captura a última query SQL executada
|
| 308 |
+
sql_query = sql_handler.get_last_sql_query()
|
| 309 |
+
|
| 310 |
+
result = {
|
| 311 |
+
"output": clean_output,
|
| 312 |
+
"intermediate_steps": response.get("intermediate_steps", []),
|
| 313 |
+
"success": True,
|
| 314 |
+
"sql_query": sql_query, # ← Query SQL capturada
|
| 315 |
+
"all_sql_queries": sql_handler.get_all_sql_queries()
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
logging.info(f"Query executada com sucesso: {result['output'][:100]}...")
|
| 319 |
+
return result
|
| 320 |
+
|
| 321 |
+
except Exception as e:
|
| 322 |
+
error_str = str(e)
|
| 323 |
+
|
| 324 |
+
# Mensagem mais amigável para problemas de rate limiting
|
| 325 |
+
if any(keyword in error_str.lower() for keyword in ['overloaded', 'rate_limit', 'too_many_requests', 'quota']):
|
| 326 |
+
error_msg = (
|
| 327 |
+
"🚫 **API da Anthropic temporariamente sobrecarregada**\n\n"
|
| 328 |
+
"A API do Claude está com muitas solicitações no momento. "
|
| 329 |
+
"Por favor, aguarde alguns minutos e tente novamente.\n\n"
|
| 330 |
+
"**Sugestões:**\n"
|
| 331 |
+
"- Aguarde 2-3 minutos antes de tentar novamente\n"
|
| 332 |
+
"- Considere usar um modelo OpenAI temporariamente\n"
|
| 333 |
+
"- Tente novamente em horários de menor movimento\n\n"
|
| 334 |
+
f"*Erro técnico: {e}*"
|
| 335 |
+
)
|
| 336 |
+
else:
|
| 337 |
+
error_msg = f"Erro ao consultar o agente SQL: {e}"
|
| 338 |
+
|
| 339 |
+
logging.error(error_msg)
|
| 340 |
+
return {
|
| 341 |
+
"output": error_msg,
|
| 342 |
+
"intermediate_steps": [],
|
| 343 |
+
"success": False
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
def get_agent_info(self) -> dict:
|
| 347 |
+
"""
|
| 348 |
+
Retorna informações sobre o agente atual
|
| 349 |
+
|
| 350 |
+
Returns:
|
| 351 |
+
Dicionário com informações do agente
|
| 352 |
+
"""
|
| 353 |
+
return {
|
| 354 |
+
"model_name": self.model_name,
|
| 355 |
+
"max_iterations": MAX_ITERATIONS,
|
| 356 |
+
"temperature": TEMPERATURE,
|
| 357 |
+
"database_tables": self.db.get_usable_table_names() if self.db else [],
|
| 358 |
+
"agent_type": "openai-tools"
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
def validate_agent(self) -> bool:
|
| 362 |
+
"""
|
| 363 |
+
Valida se o agente está funcionando corretamente
|
| 364 |
+
|
| 365 |
+
Returns:
|
| 366 |
+
True se válido, False caso contrário
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
# Testa com uma query simples
|
| 370 |
+
test_result = self.agent.invoke({
|
| 371 |
+
"input": "Quantas linhas existem na tabela?"
|
| 372 |
+
})
|
| 373 |
+
|
| 374 |
+
success = "output" in test_result and test_result["output"]
|
| 375 |
+
logging.info(f"Validação do agente: {'Sucesso' if success else 'Falha'}")
|
| 376 |
+
return success
|
| 377 |
+
|
| 378 |
+
except Exception as e:
|
| 379 |
+
logging.error(f"Erro na validação do agente: {e}")
|
| 380 |
+
return False
|
| 381 |
+
|
| 382 |
+
def get_default_sql_agent(db: SQLDatabase) -> SQLAgentManager:
|
| 383 |
+
"""
|
| 384 |
+
Cria um agente SQL com configurações padrão
|
| 385 |
+
|
| 386 |
+
Args:
|
| 387 |
+
db: Objeto SQLDatabase
|
| 388 |
+
|
| 389 |
+
Returns:
|
| 390 |
+
SQLAgentManager configurado
|
| 391 |
+
"""
|
| 392 |
+
return SQLAgentManager(db)
|
agents/tools.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Ferramentas para o agente SQL
|
| 3 |
+
"""
|
| 4 |
+
import time
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
from typing import Dict, Any, Optional, List
|
| 8 |
+
from huggingface_hub import InferenceClient
|
| 9 |
+
from langchain_community.utilities import SQLDatabase
|
| 10 |
+
from langchain_openai import ChatOpenAI
|
| 11 |
+
from langchain_anthropic import ChatAnthropic
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
from utils.config import (
|
| 15 |
+
HUGGINGFACE_API_KEY,
|
| 16 |
+
OPENAI_API_KEY,
|
| 17 |
+
ANTHROPIC_API_KEY,
|
| 18 |
+
AVAILABLE_MODELS,
|
| 19 |
+
REFINEMENT_MODELS,
|
| 20 |
+
LLAMA_MODELS,
|
| 21 |
+
MAX_TOKENS_MAP,
|
| 22 |
+
OPENAI_MODELS,
|
| 23 |
+
ANTHROPIC_MODELS,
|
| 24 |
+
HUGGINGFACE_MODELS
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Cliente HuggingFace
|
| 28 |
+
hf_client = InferenceClient(
|
| 29 |
+
provider="together",
|
| 30 |
+
api_key=HUGGINGFACE_API_KEY
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Cliente OpenAI
|
| 34 |
+
openai_client = None
|
| 35 |
+
if OPENAI_API_KEY:
|
| 36 |
+
openai_client = ChatOpenAI(
|
| 37 |
+
api_key=OPENAI_API_KEY,
|
| 38 |
+
temperature=0
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Cliente Anthropic
|
| 42 |
+
anthropic_client = None
|
| 43 |
+
if ANTHROPIC_API_KEY:
|
| 44 |
+
anthropic_client = ChatAnthropic(
|
| 45 |
+
model="claude-3-5-sonnet-20241022",
|
| 46 |
+
api_key=ANTHROPIC_API_KEY,
|
| 47 |
+
temperature=0
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Função generate_initial_context removida - era redundante
|
| 51 |
+
|
| 52 |
+
def is_greeting(user_query: str) -> bool:
|
| 53 |
+
"""
|
| 54 |
+
Verifica se a query do usuário é uma saudação
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
user_query: Query do usuário
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
True se for saudação, False caso contrário
|
| 61 |
+
"""
|
| 62 |
+
greetings = ["olá", "oi", "bom dia", "boa tarde", "boa noite", "oi, tudo bem?"]
|
| 63 |
+
return user_query.lower().strip() in greetings
|
| 64 |
+
|
| 65 |
+
def detect_query_type(user_query: str) -> str:
|
| 66 |
+
"""
|
| 67 |
+
Detecta o tipo de processamento necessário para a query do usuário
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
user_query: Pergunta do usuário
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
Tipo de processamento: 'sql_query', 'sql_query_graphic', 'prediction', 'chart'
|
| 74 |
+
"""
|
| 75 |
+
query_lower = user_query.lower().strip()
|
| 76 |
+
|
| 77 |
+
# Palavras-chave para diferentes tipos
|
| 78 |
+
prediction_keywords = ['prever', 'predizer', 'previsão', 'forecast', 'predict', 'tendência', 'projeção']
|
| 79 |
+
|
| 80 |
+
# Palavras-chave para gráficos - expandida para melhor detecção
|
| 81 |
+
chart_keywords = [
|
| 82 |
+
'gráfico', 'grafico', 'chart', 'plot', 'visualizar', 'visualização', 'visualizacao',
|
| 83 |
+
'mostrar gráfico', 'mostrar grafico', 'gerar gráfico', 'gerar grafico',
|
| 84 |
+
'criar gráfico', 'criar grafico', 'plotar', 'desenhar gráfico', 'desenhar grafico',
|
| 85 |
+
'exibir gráfico', 'exibir grafico', 'fazer gráfico', 'fazer grafico',
|
| 86 |
+
'gráfico de', 'grafico de', 'em gráfico', 'em grafico',
|
| 87 |
+
'barras', 'linha', 'pizza', 'área', 'area', 'histograma',
|
| 88 |
+
'scatter', 'dispersão', 'dispersao', 'boxplot', 'heatmap'
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
# Verifica se há solicitação de gráfico
|
| 92 |
+
has_chart_request = any(keyword in query_lower for keyword in chart_keywords)
|
| 93 |
+
|
| 94 |
+
# Verifica se há solicitação de previsão
|
| 95 |
+
has_prediction_request = any(keyword in query_lower for keyword in prediction_keywords)
|
| 96 |
+
|
| 97 |
+
# Lógica de detecção
|
| 98 |
+
if has_prediction_request:
|
| 99 |
+
return 'prediction' # Futuro: agente de ML/previsões
|
| 100 |
+
elif has_chart_request:
|
| 101 |
+
return 'sql_query_graphic' # SQL + Gráfico
|
| 102 |
+
else:
|
| 103 |
+
return 'sql_query' # SQL normal
|
| 104 |
+
|
| 105 |
+
def prepare_processing_context(user_query: str, db_sample: pd.DataFrame) -> str:
|
| 106 |
+
"""
|
| 107 |
+
Prepara o contexto inicial para o Processing Agent
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
user_query: Pergunta do usuário
|
| 111 |
+
db_sample: Amostra dos dados do banco
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
Contexto formatado para o Processing Agent
|
| 115 |
+
"""
|
| 116 |
+
# Obtém informações detalhadas das colunas
|
| 117 |
+
column_info = []
|
| 118 |
+
for col in db_sample.columns:
|
| 119 |
+
col_data = db_sample[col].dropna()
|
| 120 |
+
if len(col_data) > 0:
|
| 121 |
+
# Exemplos de valores únicos (máximo 5)
|
| 122 |
+
unique_values = col_data.unique()[:5]
|
| 123 |
+
examples = ", ".join([str(v) for v in unique_values])
|
| 124 |
+
|
| 125 |
+
# Tipo de dados
|
| 126 |
+
dtype = str(col_data.dtype)
|
| 127 |
+
|
| 128 |
+
# Estatísticas básicas para colunas numéricas
|
| 129 |
+
stats = ""
|
| 130 |
+
if col_data.dtype in ['int64', 'float64']:
|
| 131 |
+
try:
|
| 132 |
+
min_val = col_data.min()
|
| 133 |
+
max_val = col_data.max()
|
| 134 |
+
stats = f" | Min: {min_val}, Max: {max_val}"
|
| 135 |
+
except:
|
| 136 |
+
pass
|
| 137 |
+
|
| 138 |
+
column_info.append(f"- {col} ({dtype}): {examples}{stats}")
|
| 139 |
+
|
| 140 |
+
columns_description = "\n".join(column_info)
|
| 141 |
+
|
| 142 |
+
# Adiciona algumas linhas de exemplo dos dados
|
| 143 |
+
sample_rows = []
|
| 144 |
+
num_rows_to_show = min(3, len(db_sample))
|
| 145 |
+
for i in range(num_rows_to_show):
|
| 146 |
+
row_data = []
|
| 147 |
+
for col in db_sample.columns:
|
| 148 |
+
value = db_sample.iloc[i][col]
|
| 149 |
+
# Trunca valores muito longos
|
| 150 |
+
str_value = str(value)
|
| 151 |
+
if len(str_value) > 30:
|
| 152 |
+
str_value = str_value[:27] + "..."
|
| 153 |
+
row_data.append(f"{col}: {str_value}")
|
| 154 |
+
sample_rows.append(" " + " | ".join(row_data))
|
| 155 |
+
|
| 156 |
+
sample_data = "\n".join(sample_rows) if sample_rows else "Nenhuma linha de exemplo disponível"
|
| 157 |
+
|
| 158 |
+
context = f"""
|
| 159 |
+
Você é um especialista em SQL que deve analisar a pergunta do usuário e gerar uma query SQL otimizada.
|
| 160 |
+
|
| 161 |
+
INSTRUÇÕES IMPORTANTES:
|
| 162 |
+
1. Analise a pergunta do usuário e o contexto dos dados
|
| 163 |
+
2. Gere uma query SQL precisa e otimizada
|
| 164 |
+
3. Use apenas as colunas que existem na tabela "tabela"
|
| 165 |
+
4. Para cálculos complexos, use CTEs quando necessário
|
| 166 |
+
5. Inclua LIMIT quando apropriado para evitar resultados excessivos
|
| 167 |
+
6. Considere os tipos de dados e valores de exemplo
|
| 168 |
+
|
| 169 |
+
CONTEXTO DOS DADOS:
|
| 170 |
+
Nome da tabela: tabela
|
| 171 |
+
|
| 172 |
+
Colunas disponíveis com tipos e exemplos:
|
| 173 |
+
{columns_description}
|
| 174 |
+
|
| 175 |
+
Exemplos de linhas dos dados:
|
| 176 |
+
{sample_data}
|
| 177 |
+
|
| 178 |
+
PERGUNTA DO USUÁRIO:
|
| 179 |
+
{user_query}
|
| 180 |
+
|
| 181 |
+
Responda somente nesse formato:
|
| 182 |
+
|
| 183 |
+
Opção de querySQL: [QuerySQL]
|
| 184 |
+
Observações: [Observações]
|
| 185 |
+
"""
|
| 186 |
+
|
| 187 |
+
return context.strip()
|
| 188 |
+
|
| 189 |
+
def prepare_sql_context(user_query: str, db_sample: pd.DataFrame, suggested_query: str = "", query_observations: str = "") -> str:
|
| 190 |
+
"""
|
| 191 |
+
Prepara o contexto inicial para ser enviado diretamente ao agentSQL
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
user_query: Pergunta do usuário
|
| 195 |
+
db_sample: Amostra dos dados do banco
|
| 196 |
+
suggested_query: Query SQL sugerida pelo Processing Agent (opcional)
|
| 197 |
+
query_observations: Observações sobre a query sugerida (opcional)
|
| 198 |
+
|
| 199 |
+
Returns:
|
| 200 |
+
Contexto formatado para o agentSQL
|
| 201 |
+
"""
|
| 202 |
+
import logging
|
| 203 |
+
|
| 204 |
+
# Contexto base
|
| 205 |
+
contexto_base = (
|
| 206 |
+
"Você é um assistente especializado em consultas SQL, geração de querySQL e análise de dados.\n"
|
| 207 |
+
"Sua tarefa é responder à pergunta do usuário abaixo, gerando uma query SQL que retorne os dados necessários para responder a pergunta.\n\n"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Contexto com opção de query (se disponível)
|
| 211 |
+
contexto_opcao_query = ""
|
| 212 |
+
if suggested_query and suggested_query.strip():
|
| 213 |
+
logging.info(f"[SQL CONTEXT] 🎯 Incluindo query sugerida no contexto do SQL Agent")
|
| 214 |
+
|
| 215 |
+
# Mantém formatação original da query
|
| 216 |
+
contexto_opcao_query = f"Opção de querySQL:\n```sql\n{suggested_query}\n```\n\n"
|
| 217 |
+
|
| 218 |
+
if query_observations and query_observations.strip():
|
| 219 |
+
contexto_opcao_query += f"Observações:\n{query_observations}\n\n"
|
| 220 |
+
|
| 221 |
+
contexto_opcao_query += "Você pode usar esta opção de query se ela estiver correta, ou criar sua própria query.\n\n"
|
| 222 |
+
logging.info(f"[SQL CONTEXT] ✅ Contexto do SQL Agent preparado COM sugestão de query")
|
| 223 |
+
else:
|
| 224 |
+
logging.info(f"[SQL CONTEXT] ℹ️ Contexto do SQL Agent preparado SEM sugestão de query")
|
| 225 |
+
|
| 226 |
+
# Monta contexto final
|
| 227 |
+
context = contexto_base + contexto_opcao_query + f"Pergunta do usuário: \n{user_query}"
|
| 228 |
+
|
| 229 |
+
return context
|
| 230 |
+
|
| 231 |
+
async def refine_response_with_llm(
|
| 232 |
+
user_question: str,
|
| 233 |
+
sql_response: str,
|
| 234 |
+
chart_md: str = ""
|
| 235 |
+
) -> str:
|
| 236 |
+
"""
|
| 237 |
+
Refina a resposta usando um modelo LLM adicional
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
user_question: Pergunta original do usuário
|
| 241 |
+
sql_response: Resposta do agente SQL
|
| 242 |
+
chart_md: Markdown de gráficos (opcional)
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
Resposta refinada
|
| 246 |
+
"""
|
| 247 |
+
prompt = (
|
| 248 |
+
f"Pergunta do usuário:\n{user_question}\n\n"
|
| 249 |
+
f"Resposta gerada pelo agente SQL:\n{sql_response}\n\n"
|
| 250 |
+
"Sua tarefa é refinar a resposta para deixá-la mais clara, completa e compreensível em português, "
|
| 251 |
+
"mantendo a resposta original no início do texto e adicionando insights úteis sobre logística de entregas de produtos, "
|
| 252 |
+
"por exemplo: comparar com padrões típicos, identificar possíveis problemas ou sugerir ações para melhorar atrasos, performance ou custos. "
|
| 253 |
+
"Evite repetir informações sem necessidade e não invente dados."
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
logging.info(f"[DEBUG] Prompt enviado ao modelo de refinamento:\n{prompt}\n")
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
response = hf_client.chat.completions.create(
|
| 260 |
+
model=REFINEMENT_MODELS["LLaMA 70B"],
|
| 261 |
+
messages=[{"role": "system", "content": prompt}],
|
| 262 |
+
max_tokens=1200,
|
| 263 |
+
stream=False
|
| 264 |
+
)
|
| 265 |
+
improved_response = response["choices"][0]["message"]["content"]
|
| 266 |
+
logging.info(f"[DEBUG] Resposta do modelo de refinamento:\n{improved_response}\n")
|
| 267 |
+
return improved_response + ("\n\n" + chart_md if chart_md else "")
|
| 268 |
+
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logging.error(f"[ERRO] Falha ao refinar resposta com LLM: {e}")
|
| 271 |
+
return sql_response + ("\n\n" + chart_md if chart_md else "")
|
| 272 |
+
|
| 273 |
+
class CacheManager:
|
| 274 |
+
"""Gerenciador de cache para queries"""
|
| 275 |
+
|
| 276 |
+
def __init__(self):
|
| 277 |
+
self.query_cache: Dict[str, str] = {}
|
| 278 |
+
self.history_log: List[Dict[str, Any]] = []
|
| 279 |
+
self.recent_history: List[Dict[str, str]] = []
|
| 280 |
+
|
| 281 |
+
def get_cached_response(self, query: str) -> Optional[str]:
|
| 282 |
+
"""Obtém resposta do cache"""
|
| 283 |
+
return self.query_cache.get(query)
|
| 284 |
+
|
| 285 |
+
def cache_response(self, query: str, response: str):
|
| 286 |
+
"""Armazena resposta no cache"""
|
| 287 |
+
self.query_cache[query] = response
|
| 288 |
+
|
| 289 |
+
def add_to_history(self, entry: Dict[str, Any]):
|
| 290 |
+
"""Adiciona entrada ao histórico"""
|
| 291 |
+
self.history_log.append(entry)
|
| 292 |
+
|
| 293 |
+
def update_recent_history(self, user_input: str, response: str):
|
| 294 |
+
"""Atualiza histórico recente"""
|
| 295 |
+
self.recent_history.append({"role": "user", "content": user_input})
|
| 296 |
+
self.recent_history.append({"role": "assistant", "content": response})
|
| 297 |
+
|
| 298 |
+
# Mantém apenas as últimas 4 entradas (2 pares pergunta-resposta)
|
| 299 |
+
if len(self.recent_history) > 4:
|
| 300 |
+
self.recent_history.pop(0)
|
| 301 |
+
self.recent_history.pop(0)
|
| 302 |
+
|
| 303 |
+
def clear_cache(self):
|
| 304 |
+
"""Limpa todo o cache"""
|
| 305 |
+
self.query_cache.clear()
|
| 306 |
+
self.history_log.clear()
|
| 307 |
+
self.recent_history.clear()
|
| 308 |
+
|
| 309 |
+
def get_history(self) -> List[Dict[str, Any]]:
|
| 310 |
+
"""Retorna histórico completo"""
|
| 311 |
+
return self.history_log
|
| 312 |
+
|
| 313 |
+
# ==================== FUNÇÕES DE GRÁFICOS ====================
|
| 314 |
+
|
| 315 |
+
def generate_graph_type_context(user_query: str, sql_query: str, df_columns: List[str], df_sample: pd.DataFrame) -> str:
|
| 316 |
+
"""
|
| 317 |
+
Gera contexto para LLM escolher o tipo de gráfico mais adequado
|
| 318 |
+
|
| 319 |
+
Args:
|
| 320 |
+
user_query: Pergunta original do usuário
|
| 321 |
+
sql_query: Query SQL gerada pelo agente
|
| 322 |
+
df_columns: Lista de colunas retornadas pela query
|
| 323 |
+
df_sample: Amostra dos dados para análise
|
| 324 |
+
|
| 325 |
+
Returns:
|
| 326 |
+
Contexto formatado para a LLM
|
| 327 |
+
"""
|
| 328 |
+
# Criar uma descrição detalhada dos dados para ajudar a LLM a entender melhor a estrutura
|
| 329 |
+
data_description = ""
|
| 330 |
+
if not df_sample.empty:
|
| 331 |
+
# Verificar tipos de dados de forma mais robusta
|
| 332 |
+
numeric_cols = []
|
| 333 |
+
date_cols = []
|
| 334 |
+
categorical_cols = []
|
| 335 |
+
|
| 336 |
+
for col in df_sample.columns:
|
| 337 |
+
col_data = df_sample[col]
|
| 338 |
+
|
| 339 |
+
# Verifica se é numérico (incluindo strings que representam números)
|
| 340 |
+
try:
|
| 341 |
+
# Tenta converter para numérico, tratando vírgulas como separador decimal
|
| 342 |
+
if col_data.dtype == 'object':
|
| 343 |
+
test_numeric = pd.to_numeric(col_data.astype(str).str.replace(',', '.'), errors='coerce')
|
| 344 |
+
if test_numeric.notna().sum() > len(col_data) * 0.8: # 80% são números válidos
|
| 345 |
+
numeric_cols.append(col)
|
| 346 |
+
else:
|
| 347 |
+
categorical_cols.append(col)
|
| 348 |
+
elif pd.api.types.is_numeric_dtype(col_data):
|
| 349 |
+
numeric_cols.append(col)
|
| 350 |
+
elif pd.api.types.is_datetime64_any_dtype(col_data) or 'data' in col.lower():
|
| 351 |
+
date_cols.append(col)
|
| 352 |
+
else:
|
| 353 |
+
categorical_cols.append(col)
|
| 354 |
+
except:
|
| 355 |
+
categorical_cols.append(col)
|
| 356 |
+
|
| 357 |
+
# Adicionar informações sobre os primeiros valores de cada coluna
|
| 358 |
+
data_description = "\nAmostra dos dados (primeiras 3 linhas):\n"
|
| 359 |
+
data_description += df_sample.head(3).to_string(index=False)
|
| 360 |
+
|
| 361 |
+
# Adicionar análise detalhada dos tipos de dados
|
| 362 |
+
data_description += f"\n\nAnálise dos dados ({len(df_sample)} linhas total):"
|
| 363 |
+
data_description += f"\n- Total de colunas: {len(df_sample.columns)}"
|
| 364 |
+
|
| 365 |
+
if numeric_cols:
|
| 366 |
+
data_description += f"\n- Colunas NUMÉRICAS ({len(numeric_cols)}): {', '.join(numeric_cols)}"
|
| 367 |
+
# Adiciona informação sobre valores numéricos
|
| 368 |
+
for col in numeric_cols[:2]: # Máximo 2 colunas para não ficar muito longo
|
| 369 |
+
try:
|
| 370 |
+
if df_sample[col].dtype == 'object':
|
| 371 |
+
# Converte strings para números
|
| 372 |
+
numeric_values = pd.to_numeric(df_sample[col].astype(str).str.replace(',', '.'), errors='coerce')
|
| 373 |
+
min_val, max_val = numeric_values.min(), numeric_values.max()
|
| 374 |
+
else:
|
| 375 |
+
min_val, max_val = df_sample[col].min(), df_sample[col].max()
|
| 376 |
+
data_description += f"\n • {col}: valores de {min_val} a {max_val}"
|
| 377 |
+
except:
|
| 378 |
+
pass
|
| 379 |
+
|
| 380 |
+
if date_cols:
|
| 381 |
+
data_description += f"\n- Colunas de DATA/TEMPO ({len(date_cols)}): {', '.join(date_cols)}"
|
| 382 |
+
|
| 383 |
+
if categorical_cols:
|
| 384 |
+
data_description += f"\n- Colunas CATEGÓRICAS ({len(categorical_cols)}): {', '.join(categorical_cols)}"
|
| 385 |
+
# Adiciona informação sobre categorias únicas
|
| 386 |
+
for col in categorical_cols[:3]: # Máximo 3 colunas
|
| 387 |
+
unique_count = df_sample[col].nunique()
|
| 388 |
+
data_description += f"\n • {col}: {unique_count} valores únicos"
|
| 389 |
+
|
| 390 |
+
# Destaque especial para múltiplas categóricas importantes
|
| 391 |
+
if len(categorical_cols) >= 2 and len(numeric_cols) >= 1:
|
| 392 |
+
data_description += f"\n\n⚠️ ATENÇÃO: {len(categorical_cols)} colunas categóricas + {len(numeric_cols)} numérica(s) → CONSIDERE GRÁFICO AGRUPADO (6) para mostrar múltiplas dimensões!"
|
| 393 |
+
|
| 394 |
+
# Prompt ULTRA SIMPLIFICADO
|
| 395 |
+
return (
|
| 396 |
+
f"Escolha o gráfico mais adequado e de acordo com pergunta do usuário e os dados:\n\n"
|
| 397 |
+
f"COLUNAS RETORNADAS: {', '.join(df_columns)}\n\n"
|
| 398 |
+
f"DADOS: {data_description}\n\n"
|
| 399 |
+
f"PERGUNTA: {user_query}\n\n"
|
| 400 |
+
f"OPÇÕES DE GRÁFICOS::\n"
|
| 401 |
+
f"1. Linha - evolução temporal\n"
|
| 402 |
+
f"2. Multilinhas - múltiplas tendências\n"
|
| 403 |
+
f"3. Área - volume temporal\n"
|
| 404 |
+
f"4. Barras Verticais - comparar categorias (nomes curtos)\n"
|
| 405 |
+
f"5. Barras Horizontais - comparar categorias (nomes longos)\n"
|
| 406 |
+
f"6. Barras Agrupadas - múltiplas métricas\n"
|
| 407 |
+
f"7. Barras Empilhadas - partes de um todo\n"
|
| 408 |
+
f"8. Pizza - proporções (poucas categorias)\n"
|
| 409 |
+
f"9. Dona - proporções (muitas categorias)\n"
|
| 410 |
+
f"10. Pizzas Múltiplas - proporções por grupos\n\n"
|
| 411 |
+
f"Responda apenas o número (1-10)."
|
| 412 |
+
"\n\nINSTRUÇÕES FINAIS:\n"
|
| 413 |
+
"1. PRIMEIRO: Verifique se o usuário especificou um tipo de gráfico na pergunta do usuário\n"
|
| 414 |
+
"2. SE SIM: Use o gráfico solicitado (consulte o mapeamento acima)\n"
|
| 415 |
+
"3. SE NÃO: Escolha o gráfico mais adequado\n\n"
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
def extract_sql_query_from_response(agent_response: str) -> Optional[str]:
|
| 419 |
+
"""
|
| 420 |
+
Extrai a query SQL da resposta do agente SQL
|
| 421 |
+
|
| 422 |
+
Args:
|
| 423 |
+
agent_response: Resposta completa do agente SQL
|
| 424 |
+
|
| 425 |
+
Returns:
|
| 426 |
+
Query SQL extraída ou None se não encontrada
|
| 427 |
+
"""
|
| 428 |
+
if not agent_response:
|
| 429 |
+
return None
|
| 430 |
+
|
| 431 |
+
# Padrões para encontrar SQL na resposta - ordem de prioridade
|
| 432 |
+
sql_patterns = [
|
| 433 |
+
# Padrão mais comum: ```sql ... ``` (multiline)
|
| 434 |
+
r"```sql\s*(.*?)\s*```",
|
| 435 |
+
# Padrão alternativo: ``` ... ``` com SELECT (multiline)
|
| 436 |
+
r"```\s*(SELECT.*?)\s*```",
|
| 437 |
+
# SELECT com múltiplas linhas até ponto e vírgula
|
| 438 |
+
r"(SELECT\s+.*?;)",
|
| 439 |
+
# SELECT com múltiplas linhas até quebra dupla ou final
|
| 440 |
+
r"(SELECT\s+.*?)(?:\n\s*\n|\n\s*$|\n\s*Agora|\n\s*Em seguida)",
|
| 441 |
+
# Padrões com prefixos específicos
|
| 442 |
+
r"Query:\s*(SELECT.*?)(?:\n|$|;)",
|
| 443 |
+
r"SQL:\s*(SELECT.*?)(?:\n|$|;)",
|
| 444 |
+
r"Consulta:\s*(SELECT.*?)(?:\n|$|;)",
|
| 445 |
+
# SELECT em uma linha
|
| 446 |
+
r"(SELECT\s+[^\n]+)",
|
| 447 |
+
]
|
| 448 |
+
|
| 449 |
+
for i, pattern in enumerate(sql_patterns):
|
| 450 |
+
matches = re.findall(pattern, agent_response, re.DOTALL | re.IGNORECASE)
|
| 451 |
+
if matches:
|
| 452 |
+
# Pega a primeira query encontrada
|
| 453 |
+
query = matches[0].strip()
|
| 454 |
+
|
| 455 |
+
# Limpa a query
|
| 456 |
+
query = clean_sql_query(query)
|
| 457 |
+
|
| 458 |
+
# Verifica se é uma query válida
|
| 459 |
+
if is_valid_sql_query(query):
|
| 460 |
+
logging.info(f"[GRAPH] Query SQL extraída (padrão {i+1}): {query[:100]}...")
|
| 461 |
+
return query
|
| 462 |
+
|
| 463 |
+
# Log da resposta para debug se não encontrar SQL
|
| 464 |
+
logging.warning(f"[GRAPH] Não foi possível extrair query SQL. Resposta (primeiros 200 chars): {agent_response[:200]}...")
|
| 465 |
+
return None
|
| 466 |
+
|
| 467 |
+
def clean_sql_query(query: str) -> str:
|
| 468 |
+
"""
|
| 469 |
+
Limpa e normaliza a query SQL extraída
|
| 470 |
+
|
| 471 |
+
Args:
|
| 472 |
+
query: Query SQL bruta
|
| 473 |
+
|
| 474 |
+
Returns:
|
| 475 |
+
Query SQL limpa
|
| 476 |
+
"""
|
| 477 |
+
if not query:
|
| 478 |
+
return ""
|
| 479 |
+
|
| 480 |
+
# Remove espaços extras e quebras de linha desnecessárias
|
| 481 |
+
query = re.sub(r'\s+', ' ', query.strip())
|
| 482 |
+
|
| 483 |
+
# Remove ponto e vírgula no final se existir
|
| 484 |
+
if query.endswith(';'):
|
| 485 |
+
query = query[:-1].strip()
|
| 486 |
+
|
| 487 |
+
# Remove aspas ou caracteres especiais no início/fim
|
| 488 |
+
query = query.strip('`"\'')
|
| 489 |
+
|
| 490 |
+
return query
|
| 491 |
+
|
| 492 |
+
def is_valid_sql_query(query: str) -> bool:
|
| 493 |
+
"""
|
| 494 |
+
Verifica se a string é uma query SQL válida
|
| 495 |
+
|
| 496 |
+
Args:
|
| 497 |
+
query: String para verificar
|
| 498 |
+
|
| 499 |
+
Returns:
|
| 500 |
+
True se for uma query SQL válida
|
| 501 |
+
"""
|
| 502 |
+
if not query or len(query.strip()) < 6: # Mínimo para "SELECT"
|
| 503 |
+
return False
|
| 504 |
+
|
| 505 |
+
# Verifica se começa com comando SQL válido
|
| 506 |
+
sql_commands = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'WITH']
|
| 507 |
+
query_upper = query.strip().upper()
|
| 508 |
+
|
| 509 |
+
return any(query_upper.startswith(cmd) for cmd in sql_commands)
|
app.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AgentGraph - Aplicação principal com interface Gradio e LangGraph
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import tempfile
|
| 8 |
+
import os
|
| 9 |
+
from typing import List, Tuple, Optional, Dict
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from graphs.main_graph import initialize_graph, get_graph_manager
|
| 13 |
+
from utils.config import (
|
| 14 |
+
AVAILABLE_MODELS,
|
| 15 |
+
REFINEMENT_MODELS,
|
| 16 |
+
DEFAULT_MODEL,
|
| 17 |
+
GRADIO_SHARE,
|
| 18 |
+
GRADIO_PORT,
|
| 19 |
+
validate_config,
|
| 20 |
+
is_langsmith_enabled,
|
| 21 |
+
LANGSMITH_PROJECT
|
| 22 |
+
)
|
| 23 |
+
from utils.object_manager import get_object_manager
|
| 24 |
+
|
| 25 |
+
# Configuração de logging
|
| 26 |
+
logging.basicConfig(
|
| 27 |
+
level=logging.INFO,
|
| 28 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Variáveis globais
|
| 32 |
+
graph_manager = None
|
| 33 |
+
show_history_flag = False
|
| 34 |
+
|
| 35 |
+
async def initialize_app():
|
| 36 |
+
"""Inicializa a aplicação"""
|
| 37 |
+
global graph_manager
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
# Valida configurações
|
| 41 |
+
validate_config()
|
| 42 |
+
|
| 43 |
+
# Inicializa o grafo
|
| 44 |
+
graph_manager = await initialize_graph()
|
| 45 |
+
|
| 46 |
+
# Informa sobre o status do LangSmith
|
| 47 |
+
if is_langsmith_enabled():
|
| 48 |
+
logging.info(f"✅ LangSmith habilitado - Projeto: '{LANGSMITH_PROJECT}'")
|
| 49 |
+
logging.info("🔍 Traces serão enviados para LangSmith automaticamente")
|
| 50 |
+
else:
|
| 51 |
+
logging.info("ℹ️ LangSmith não configurado - Executando sem observabilidade")
|
| 52 |
+
|
| 53 |
+
logging.info("Aplicação inicializada com sucesso")
|
| 54 |
+
return True
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logging.error(f"Erro ao inicializar aplicação: {e}")
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
def run_async(coro):
|
| 61 |
+
"""Executa corrotina de forma síncrona"""
|
| 62 |
+
try:
|
| 63 |
+
loop = asyncio.get_event_loop()
|
| 64 |
+
except RuntimeError:
|
| 65 |
+
loop = asyncio.new_event_loop()
|
| 66 |
+
asyncio.set_event_loop(loop)
|
| 67 |
+
|
| 68 |
+
return loop.run_until_complete(coro)
|
| 69 |
+
|
| 70 |
+
def chatbot_response(user_input: str, selected_model: str, advanced_mode: bool = False, processing_enabled: bool = False, processing_model: str = "GPT-4o-mini") -> Tuple[str, Optional[str]]:
|
| 71 |
+
"""
|
| 72 |
+
Processa resposta do chatbot usando LangGraph
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
user_input: Entrada do usuário
|
| 76 |
+
selected_model: Modelo LLM selecionado
|
| 77 |
+
advanced_mode: Se deve usar refinamento avançado
|
| 78 |
+
processing_enabled: Se o Processing Agent está habilitado
|
| 79 |
+
processing_model: Modelo para o Processing Agent
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Tupla com (resposta_texto, caminho_imagem_grafico)
|
| 83 |
+
"""
|
| 84 |
+
global graph_manager
|
| 85 |
+
|
| 86 |
+
if not graph_manager:
|
| 87 |
+
return "❌ Sistema não inicializado. Tente recarregar a página.", None
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
# Processa query através do LangGraph
|
| 91 |
+
result = run_async(graph_manager.process_query(
|
| 92 |
+
user_input=user_input,
|
| 93 |
+
selected_model=selected_model,
|
| 94 |
+
advanced_mode=advanced_mode,
|
| 95 |
+
processing_enabled=processing_enabled,
|
| 96 |
+
processing_model=processing_model
|
| 97 |
+
))
|
| 98 |
+
|
| 99 |
+
response_text = result.get("response", "Erro ao processar resposta")
|
| 100 |
+
graph_image_path = None
|
| 101 |
+
|
| 102 |
+
# Verifica se foi gerado um gráfico
|
| 103 |
+
if result.get("graph_generated", False) and result.get("graph_image_id"):
|
| 104 |
+
graph_image_path = save_graph_image_to_temp(result["graph_image_id"])
|
| 105 |
+
|
| 106 |
+
# Adiciona informação sobre o gráfico na resposta
|
| 107 |
+
if graph_image_path:
|
| 108 |
+
graph_type = result.get("graph_type", "gráfico")
|
| 109 |
+
response_text += f"\n\n📊 **Gráfico gerado**: {graph_type.replace('_', ' ').title()}"
|
| 110 |
+
|
| 111 |
+
return response_text, graph_image_path
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
error_msg = f"Erro no chatbot: {e}"
|
| 115 |
+
logging.error(error_msg)
|
| 116 |
+
logging.error(f"Detalhes do erro: {type(e).__name__}: {str(e)}")
|
| 117 |
+
return error_msg, None
|
| 118 |
+
|
| 119 |
+
def save_graph_image_to_temp(graph_image_id: str) -> Optional[str]:
|
| 120 |
+
"""
|
| 121 |
+
Salva imagem do gráfico em arquivo temporário para exibição no Gradio
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
graph_image_id: ID da imagem no ObjectManager
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Caminho do arquivo temporário ou None se falhar
|
| 128 |
+
"""
|
| 129 |
+
try:
|
| 130 |
+
obj_manager = get_object_manager()
|
| 131 |
+
graph_image = obj_manager.get_object(graph_image_id)
|
| 132 |
+
|
| 133 |
+
if graph_image and isinstance(graph_image, Image.Image):
|
| 134 |
+
# Cria arquivo temporário
|
| 135 |
+
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png')
|
| 136 |
+
graph_image.save(temp_file.name, format='PNG')
|
| 137 |
+
temp_file.close()
|
| 138 |
+
|
| 139 |
+
logging.info(f"[GRADIO] Gráfico salvo em: {temp_file.name}")
|
| 140 |
+
return temp_file.name
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logging.error(f"[GRADIO] Erro ao salvar gráfico: {e}")
|
| 144 |
+
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
def handle_csv_upload(file) -> str:
|
| 148 |
+
"""
|
| 149 |
+
Processa upload de arquivo CSV
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
file: Arquivo enviado pelo Gradio
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
Mensagem de feedback
|
| 156 |
+
"""
|
| 157 |
+
global graph_manager
|
| 158 |
+
|
| 159 |
+
if not graph_manager:
|
| 160 |
+
return "❌ Sistema não inicializado."
|
| 161 |
+
|
| 162 |
+
if not file:
|
| 163 |
+
return "❌ Nenhum arquivo selecionado."
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
# Log detalhado do arquivo recebido
|
| 167 |
+
logging.info(f"[UPLOAD] Arquivo recebido: {file}")
|
| 168 |
+
logging.info(f"[UPLOAD] Nome do arquivo: {file.name}")
|
| 169 |
+
logging.info(f"[UPLOAD] Tipo do arquivo: {type(file)}")
|
| 170 |
+
|
| 171 |
+
# Verifica se o arquivo existe
|
| 172 |
+
import os
|
| 173 |
+
if not os.path.exists(file.name):
|
| 174 |
+
return f"❌ Arquivo não encontrado: {file.name}"
|
| 175 |
+
|
| 176 |
+
# Verifica se é um arquivo CSV
|
| 177 |
+
if not file.name.lower().endswith('.csv'):
|
| 178 |
+
return "❌ Por favor, selecione um arquivo CSV válido."
|
| 179 |
+
|
| 180 |
+
# Verifica o tamanho do arquivo
|
| 181 |
+
file_size = os.path.getsize(file.name)
|
| 182 |
+
file_size_mb = file_size / (1024 * 1024)
|
| 183 |
+
file_size_gb = file_size / (1024 * 1024 * 1024)
|
| 184 |
+
|
| 185 |
+
if file_size_gb >= 1:
|
| 186 |
+
size_str = f"{file_size_gb:.2f} GB"
|
| 187 |
+
else:
|
| 188 |
+
size_str = f"{file_size_mb:.2f} MB"
|
| 189 |
+
|
| 190 |
+
logging.info(f"[UPLOAD] Tamanho do arquivo: {file_size} bytes ({size_str})")
|
| 191 |
+
|
| 192 |
+
if file_size == 0:
|
| 193 |
+
return "❌ O arquivo está vazio."
|
| 194 |
+
|
| 195 |
+
if file_size > 5 * 1024 * 1024 * 1024: # 5GB
|
| 196 |
+
return "❌ Arquivo muito grande. Máximo permitido: 5GB."
|
| 197 |
+
|
| 198 |
+
# Aviso para arquivos grandes
|
| 199 |
+
if file_size_mb > 100:
|
| 200 |
+
logging.info(f"[UPLOAD] Arquivo grande detectado ({size_str}). Processamento pode demorar...")
|
| 201 |
+
return f"⏳ Processando arquivo grande ({size_str}). Aguarde..."
|
| 202 |
+
|
| 203 |
+
# Processa upload através do LangGraph
|
| 204 |
+
logging.info(f"[UPLOAD] Iniciando processamento do arquivo: {file.name}")
|
| 205 |
+
result = run_async(graph_manager.handle_csv_upload(file.name))
|
| 206 |
+
|
| 207 |
+
logging.info(f"[UPLOAD] Resultado do processamento: {result}")
|
| 208 |
+
return result.get("message", "Erro no upload")
|
| 209 |
+
|
| 210 |
+
except Exception as e:
|
| 211 |
+
error_msg = f"❌ Erro ao processar upload: {e}"
|
| 212 |
+
logging.error(error_msg)
|
| 213 |
+
logging.error(f"[UPLOAD] Detalhes do erro: {type(e).__name__}: {str(e)}")
|
| 214 |
+
import traceback
|
| 215 |
+
logging.error(f"[UPLOAD] Traceback: {traceback.format_exc()}")
|
| 216 |
+
return error_msg
|
| 217 |
+
|
| 218 |
+
def reset_system() -> str:
|
| 219 |
+
"""
|
| 220 |
+
Reseta o sistema ao estado inicial
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
Mensagem de feedback
|
| 224 |
+
"""
|
| 225 |
+
global graph_manager
|
| 226 |
+
|
| 227 |
+
if not graph_manager:
|
| 228 |
+
return "❌ Sistema não inicializado."
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
# Reseta sistema através do LangGraph
|
| 232 |
+
result = run_async(graph_manager.reset_system())
|
| 233 |
+
|
| 234 |
+
return result.get("message", "Erro no reset")
|
| 235 |
+
|
| 236 |
+
except Exception as e:
|
| 237 |
+
error_msg = f"❌ Erro ao resetar sistema: {e}"
|
| 238 |
+
logging.error(error_msg)
|
| 239 |
+
return error_msg
|
| 240 |
+
|
| 241 |
+
def toggle_advanced_mode(enabled: bool) -> str:
|
| 242 |
+
"""
|
| 243 |
+
Alterna modo avançado
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
enabled: Se deve habilitar modo avançado
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
Mensagem de status
|
| 250 |
+
"""
|
| 251 |
+
global graph_manager
|
| 252 |
+
|
| 253 |
+
if not graph_manager:
|
| 254 |
+
return "❌ Sistema não inicializado."
|
| 255 |
+
|
| 256 |
+
return graph_manager.toggle_advanced_mode(enabled)
|
| 257 |
+
|
| 258 |
+
def toggle_history():
|
| 259 |
+
"""Alterna exibição do histórico"""
|
| 260 |
+
global show_history_flag, graph_manager
|
| 261 |
+
|
| 262 |
+
show_history_flag = not show_history_flag
|
| 263 |
+
|
| 264 |
+
if show_history_flag and graph_manager:
|
| 265 |
+
return graph_manager.get_history()
|
| 266 |
+
else:
|
| 267 |
+
return {}
|
| 268 |
+
|
| 269 |
+
def respond(message: str, chat_history: List[Dict[str, str]], selected_model: str, advanced_mode: bool, processing_enabled: bool = False, processing_model: str = "GPT-4o-mini"):
|
| 270 |
+
"""
|
| 271 |
+
Função de resposta para o chatbot Gradio
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
message: Mensagem do usuário
|
| 275 |
+
chat_history: Histórico do chat (formato messages)
|
| 276 |
+
selected_model: Modelo selecionado
|
| 277 |
+
advanced_mode: Modo avançado habilitado
|
| 278 |
+
processing_enabled: Se o Processing Agent está habilitado
|
| 279 |
+
processing_model: Modelo para o Processing Agent
|
| 280 |
+
|
| 281 |
+
Returns:
|
| 282 |
+
Tupla com (mensagem_vazia, histórico_atualizado, imagem_grafico)
|
| 283 |
+
"""
|
| 284 |
+
import logging
|
| 285 |
+
|
| 286 |
+
logging.info(f"[GRADIO RESPOND] ===== NOVA REQUISIÇÃO =====")
|
| 287 |
+
logging.info(f"[GRADIO RESPOND] Message: {message}")
|
| 288 |
+
logging.info(f"[GRADIO RESPOND] Selected model: {selected_model}")
|
| 289 |
+
logging.info(f"[GRADIO RESPOND] Advanced mode: {advanced_mode}")
|
| 290 |
+
logging.info(f"[GRADIO RESPOND] Processing enabled: {processing_enabled}")
|
| 291 |
+
logging.info(f"[GRADIO RESPOND] Processing model: {processing_model}")
|
| 292 |
+
|
| 293 |
+
if not message.strip():
|
| 294 |
+
return "", chat_history, None
|
| 295 |
+
|
| 296 |
+
# Processa resposta
|
| 297 |
+
response, graph_image_path = chatbot_response(message, selected_model, advanced_mode, processing_enabled, processing_model)
|
| 298 |
+
|
| 299 |
+
# Atualiza histórico no formato messages
|
| 300 |
+
chat_history.append({"role": "user", "content": message})
|
| 301 |
+
chat_history.append({"role": "assistant", "content": response})
|
| 302 |
+
|
| 303 |
+
return "", chat_history, graph_image_path
|
| 304 |
+
|
| 305 |
+
def handle_csv_and_clear_chat(file):
|
| 306 |
+
"""
|
| 307 |
+
Processa CSV e limpa chat
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
file: Arquivo CSV
|
| 311 |
+
|
| 312 |
+
Returns:
|
| 313 |
+
Tupla com (feedback, chat_limpo, grafico_limpo)
|
| 314 |
+
"""
|
| 315 |
+
feedback = handle_csv_upload(file)
|
| 316 |
+
return feedback, [], gr.update(visible=False)
|
| 317 |
+
|
| 318 |
+
def reset_all():
|
| 319 |
+
"""
|
| 320 |
+
Reseta tudo e limpa interface
|
| 321 |
+
|
| 322 |
+
Returns:
|
| 323 |
+
Tupla com (feedback, chat_limpo, arquivo_limpo, grafico_limpo)
|
| 324 |
+
"""
|
| 325 |
+
feedback = reset_system()
|
| 326 |
+
return feedback, [], None, gr.update(visible=False)
|
| 327 |
+
|
| 328 |
+
# Interface Gradio
|
| 329 |
+
def create_interface():
|
| 330 |
+
"""Cria interface Gradio"""
|
| 331 |
+
|
| 332 |
+
# CSS customizado para pequeno espaçamento lateral
|
| 333 |
+
custom_css = """
|
| 334 |
+
.gradio-container {
|
| 335 |
+
padding: 20px 30px !important;
|
| 336 |
+
}
|
| 337 |
+
"""
|
| 338 |
+
|
| 339 |
+
with gr.Blocks(theme=gr.themes.Soft(), css=custom_css) as demo:
|
| 340 |
+
|
| 341 |
+
with gr.Row():
|
| 342 |
+
with gr.Column(scale=1):
|
| 343 |
+
gr.Markdown("## Configurações")
|
| 344 |
+
model_selector = gr.Dropdown(list(AVAILABLE_MODELS.keys()), value=DEFAULT_MODEL, label="")
|
| 345 |
+
csv_file = gr.File(file_types=[".csv"], label="")
|
| 346 |
+
upload_feedback = gr.Markdown()
|
| 347 |
+
advanced_checkbox = gr.Checkbox(label="Refinar Resposta")
|
| 348 |
+
|
| 349 |
+
# Controles do Processing Agent
|
| 350 |
+
processing_checkbox = gr.Checkbox(label="Usar Processing Agent", value=False)
|
| 351 |
+
processing_model_selector = gr.Dropdown(
|
| 352 |
+
choices=list(AVAILABLE_MODELS.keys()) + list(REFINEMENT_MODELS.keys()),
|
| 353 |
+
value="GPT-4o-mini", # Chave correta do AVAILABLE_MODELS
|
| 354 |
+
label="Modelo do Processing Agent",
|
| 355 |
+
visible=False
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# Status do LangSmith
|
| 359 |
+
if is_langsmith_enabled():
|
| 360 |
+
gr.Markdown(f"🔍 **LangSmith**: Ativo")
|
| 361 |
+
else:
|
| 362 |
+
gr.Markdown("🔍 **LangSmith**: Desabilitado")
|
| 363 |
+
|
| 364 |
+
reset_btn = gr.Button("Resetar")
|
| 365 |
+
|
| 366 |
+
with gr.Column(scale=4):
|
| 367 |
+
gr.Markdown("## Agent86")
|
| 368 |
+
chatbot = gr.Chatbot(
|
| 369 |
+
height=600,
|
| 370 |
+
show_label=False,
|
| 371 |
+
container=True,
|
| 372 |
+
type="messages"
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
msg = gr.Textbox(placeholder="Digite sua pergunta aqui...", lines=1, label="")
|
| 376 |
+
btn = gr.Button("Enviar", variant="primary")
|
| 377 |
+
history_btn = gr.Button("Histórico", variant="secondary")
|
| 378 |
+
history_output = gr.JSON()
|
| 379 |
+
|
| 380 |
+
# Componente para exibir gráficos - posicionado após histórico
|
| 381 |
+
graph_image = gr.Image(
|
| 382 |
+
label="📊 Visualização de Dados",
|
| 383 |
+
visible=False,
|
| 384 |
+
height=500, # Altura maior para ocupar mais espaço
|
| 385 |
+
show_label=True,
|
| 386 |
+
container=True,
|
| 387 |
+
interactive=False,
|
| 388 |
+
show_download_button=True
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
download_file = gr.File(visible=False)
|
| 392 |
+
|
| 393 |
+
# Event handlers (usando as funções originais do sistema)
|
| 394 |
+
def handle_response_with_graph(message, chat_history, model, advanced, processing_enabled, processing_model):
|
| 395 |
+
"""Wrapper para lidar com resposta e gráfico"""
|
| 396 |
+
empty_msg, updated_history, graph_path = respond(message, chat_history, model, advanced, processing_enabled, processing_model)
|
| 397 |
+
|
| 398 |
+
# Controla visibilidade do componente de gráfico
|
| 399 |
+
if graph_path:
|
| 400 |
+
return empty_msg, updated_history, gr.update(value=graph_path, visible=True)
|
| 401 |
+
else:
|
| 402 |
+
return empty_msg, updated_history, gr.update(visible=False)
|
| 403 |
+
|
| 404 |
+
def toggle_processing_agent(enabled):
|
| 405 |
+
"""Controla visibilidade do seletor de modelo do Processing Agent"""
|
| 406 |
+
return gr.update(visible=enabled)
|
| 407 |
+
|
| 408 |
+
msg.submit(
|
| 409 |
+
handle_response_with_graph,
|
| 410 |
+
inputs=[msg, chatbot, model_selector, advanced_checkbox, processing_checkbox, processing_model_selector],
|
| 411 |
+
outputs=[msg, chatbot, graph_image]
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
btn.click(
|
| 415 |
+
handle_response_with_graph,
|
| 416 |
+
inputs=[msg, chatbot, model_selector, advanced_checkbox, processing_checkbox, processing_model_selector],
|
| 417 |
+
outputs=[msg, chatbot, graph_image]
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
csv_file.change(
|
| 421 |
+
handle_csv_and_clear_chat,
|
| 422 |
+
inputs=csv_file,
|
| 423 |
+
outputs=[upload_feedback, chatbot, graph_image]
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
reset_btn.click(
|
| 427 |
+
reset_all,
|
| 428 |
+
outputs=[upload_feedback, chatbot, csv_file, graph_image]
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
advanced_checkbox.change(
|
| 432 |
+
toggle_advanced_mode,
|
| 433 |
+
inputs=advanced_checkbox,
|
| 434 |
+
outputs=[]
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
history_btn.click(
|
| 438 |
+
toggle_history,
|
| 439 |
+
outputs=history_output
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
processing_checkbox.change(
|
| 443 |
+
toggle_processing_agent,
|
| 444 |
+
inputs=processing_checkbox,
|
| 445 |
+
outputs=processing_model_selector
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
return demo
|
| 449 |
+
|
| 450 |
+
async def main():
|
| 451 |
+
"""Função principal"""
|
| 452 |
+
# Inicializa aplicação
|
| 453 |
+
success = await initialize_app()
|
| 454 |
+
|
| 455 |
+
if not success:
|
| 456 |
+
logging.error("Falha na inicialização. Encerrando aplicação.")
|
| 457 |
+
return
|
| 458 |
+
|
| 459 |
+
# Cria e lança interface
|
| 460 |
+
demo = create_interface()
|
| 461 |
+
|
| 462 |
+
# Tenta diferentes portas se a padrão estiver ocupada
|
| 463 |
+
ports_to_try = [GRADIO_PORT, 7861, 7862, 7863, 7864, 0] # 0 = porta automática
|
| 464 |
+
|
| 465 |
+
for port in ports_to_try:
|
| 466 |
+
try:
|
| 467 |
+
logging.info(f"Tentando iniciar interface Gradio na porta {port}")
|
| 468 |
+
demo.launch(
|
| 469 |
+
share=GRADIO_SHARE,
|
| 470 |
+
server_port=port if port != 0 else None,
|
| 471 |
+
show_error=True,
|
| 472 |
+
quiet=False
|
| 473 |
+
)
|
| 474 |
+
break # Se chegou aqui, deu certo
|
| 475 |
+
except OSError as e:
|
| 476 |
+
if "Cannot find empty port" in str(e) and port != ports_to_try[-1]:
|
| 477 |
+
logging.warning(f"Porta {port} ocupada, tentando próxima...")
|
| 478 |
+
continue
|
| 479 |
+
else:
|
| 480 |
+
logging.error(f"Erro ao iniciar servidor: {e}")
|
| 481 |
+
raise
|
| 482 |
+
except Exception as e:
|
| 483 |
+
logging.error(f"Erro inesperado ao iniciar interface: {e}")
|
| 484 |
+
raise
|
| 485 |
+
|
| 486 |
+
if __name__ == "__main__":
|
| 487 |
+
run_async(main())
|
graphs/main_graph.py
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Grafo principal do LangGraph para o AgentGraph
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import re
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
+
from langgraph.graph import StateGraph, END
|
| 9 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 10 |
+
from sqlalchemy import Integer, Float, DateTime
|
| 11 |
+
|
| 12 |
+
from nodes.agent_node import (
|
| 13 |
+
AgentState,
|
| 14 |
+
should_refine_response,
|
| 15 |
+
should_generate_graph,
|
| 16 |
+
should_use_processing_agent,
|
| 17 |
+
route_after_cache_check
|
| 18 |
+
)
|
| 19 |
+
from nodes.csv_processing_node import csv_processing_node
|
| 20 |
+
from nodes.database_node import (
|
| 21 |
+
create_database_from_dataframe_node,
|
| 22 |
+
load_existing_database_node,
|
| 23 |
+
get_database_sample_node
|
| 24 |
+
)
|
| 25 |
+
from nodes.query_node import (
|
| 26 |
+
validate_query_input_node,
|
| 27 |
+
prepare_query_context_node,
|
| 28 |
+
process_user_query_node
|
| 29 |
+
)
|
| 30 |
+
from nodes.refinement_node import (
|
| 31 |
+
refine_response_node,
|
| 32 |
+
format_final_response_node
|
| 33 |
+
)
|
| 34 |
+
from nodes.processing_node import (
|
| 35 |
+
process_initial_context_node,
|
| 36 |
+
validate_processing_input_node
|
| 37 |
+
)
|
| 38 |
+
from nodes.cache_node import (
|
| 39 |
+
check_cache_node,
|
| 40 |
+
cache_response_node,
|
| 41 |
+
update_history_node
|
| 42 |
+
)
|
| 43 |
+
from nodes.graph_selection_node import graph_selection_node
|
| 44 |
+
from nodes.graph_generation_node import graph_generation_node
|
| 45 |
+
from nodes.custom_nodes import CustomNodeManager
|
| 46 |
+
from agents.sql_agent import SQLAgentManager
|
| 47 |
+
from agents.tools import CacheManager
|
| 48 |
+
from utils.database import create_sql_database
|
| 49 |
+
from utils.config import get_active_csv_path, SQL_DB_PATH
|
| 50 |
+
from utils.object_manager import get_object_manager
|
| 51 |
+
|
| 52 |
+
class AgentGraphManager:
|
| 53 |
+
"""
|
| 54 |
+
Gerenciador principal do grafo LangGraph
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
def __init__(self):
|
| 58 |
+
self.graph = None
|
| 59 |
+
self.app = None
|
| 60 |
+
self.cache_manager = CacheManager()
|
| 61 |
+
self.custom_node_manager = CustomNodeManager()
|
| 62 |
+
self.object_manager = get_object_manager()
|
| 63 |
+
self.engine = None
|
| 64 |
+
self.sql_agent = None
|
| 65 |
+
self.db = None
|
| 66 |
+
# IDs para objetos não-serializáveis
|
| 67 |
+
self.agent_id = None
|
| 68 |
+
self.engine_id = None
|
| 69 |
+
self.db_id = None
|
| 70 |
+
self.cache_id = None
|
| 71 |
+
self._initialize_system()
|
| 72 |
+
self._build_graph()
|
| 73 |
+
|
| 74 |
+
def _initialize_system(self):
|
| 75 |
+
"""Inicializa o sistema com banco e agente padrão"""
|
| 76 |
+
try:
|
| 77 |
+
# Para inicialização síncrona, vamos usar load_existing_database_node de forma síncrona
|
| 78 |
+
# ou criar uma versão síncrona temporária
|
| 79 |
+
import os
|
| 80 |
+
from sqlalchemy import create_engine
|
| 81 |
+
|
| 82 |
+
# Verifica se banco existe
|
| 83 |
+
if os.path.exists(SQL_DB_PATH):
|
| 84 |
+
# Carrega banco existente
|
| 85 |
+
self.engine = create_engine(f"sqlite:///{SQL_DB_PATH}")
|
| 86 |
+
db = create_sql_database(self.engine)
|
| 87 |
+
logging.info("Banco existente carregado")
|
| 88 |
+
else:
|
| 89 |
+
# Cria novo banco usando função síncrona temporária
|
| 90 |
+
csv_path = get_active_csv_path()
|
| 91 |
+
self.engine = self._create_engine_sync(csv_path)
|
| 92 |
+
db = create_sql_database(self.engine)
|
| 93 |
+
logging.info("Novo banco criado")
|
| 94 |
+
|
| 95 |
+
# Armazena banco de dados
|
| 96 |
+
self.db = db
|
| 97 |
+
self.db_id = self.object_manager.store_database(db)
|
| 98 |
+
|
| 99 |
+
# Cria agente SQL
|
| 100 |
+
self.sql_agent = SQLAgentManager(db)
|
| 101 |
+
|
| 102 |
+
# Armazena objetos no gerenciador
|
| 103 |
+
self.agent_id = self.object_manager.store_sql_agent(self.sql_agent, self.db_id)
|
| 104 |
+
self.engine_id = self.object_manager.store_engine(self.engine)
|
| 105 |
+
self.cache_id = self.object_manager.store_cache_manager(self.cache_manager)
|
| 106 |
+
|
| 107 |
+
logging.info("Sistema inicializado com sucesso")
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logging.error(f"Erro ao inicializar sistema: {e}")
|
| 111 |
+
raise
|
| 112 |
+
|
| 113 |
+
def _create_engine_sync(self, csv_path: str):
|
| 114 |
+
"""Cria engine de forma síncrona para inicialização"""
|
| 115 |
+
import pandas as pd
|
| 116 |
+
from sqlalchemy import create_engine
|
| 117 |
+
from sqlalchemy.types import DateTime, Integer, Float
|
| 118 |
+
|
| 119 |
+
# Lê CSV
|
| 120 |
+
df = pd.read_csv(csv_path, sep=';')
|
| 121 |
+
|
| 122 |
+
# Processamento inteligente de tipos
|
| 123 |
+
sql_types = {}
|
| 124 |
+
df = self._smart_type_conversion(df, sql_types)
|
| 125 |
+
|
| 126 |
+
# Cria engine e salva dados
|
| 127 |
+
engine = create_engine(f"sqlite:///{SQL_DB_PATH}")
|
| 128 |
+
df.to_sql("tabela", engine, index=False, if_exists="replace", dtype=sql_types)
|
| 129 |
+
|
| 130 |
+
logging.info(f"Banco criado com {len(df)} registros")
|
| 131 |
+
return engine
|
| 132 |
+
|
| 133 |
+
def _build_graph(self):
|
| 134 |
+
"""Constrói o grafo LangGraph com nova arquitetura"""
|
| 135 |
+
try:
|
| 136 |
+
# Cria o StateGraph
|
| 137 |
+
workflow = StateGraph(AgentState)
|
| 138 |
+
|
| 139 |
+
# Adiciona nós de validação e preparação
|
| 140 |
+
workflow.add_node("validate_input", validate_query_input_node)
|
| 141 |
+
workflow.add_node("check_cache", check_cache_node)
|
| 142 |
+
workflow.add_node("validate_processing", validate_processing_input_node)
|
| 143 |
+
workflow.add_node("process_initial_context", process_initial_context_node)
|
| 144 |
+
workflow.add_node("prepare_context", prepare_query_context_node)
|
| 145 |
+
workflow.add_node("get_db_sample", get_database_sample_node)
|
| 146 |
+
|
| 147 |
+
# Adiciona nós de processamento
|
| 148 |
+
workflow.add_node("process_query", process_user_query_node)
|
| 149 |
+
|
| 150 |
+
# Adiciona nós de gráficos
|
| 151 |
+
workflow.add_node("graph_selection", graph_selection_node)
|
| 152 |
+
workflow.add_node("graph_generation", graph_generation_node)
|
| 153 |
+
|
| 154 |
+
# Adiciona nós de refinamento
|
| 155 |
+
workflow.add_node("refine_response", refine_response_node)
|
| 156 |
+
workflow.add_node("format_response", format_final_response_node)
|
| 157 |
+
|
| 158 |
+
# Adiciona nós de cache e histórico
|
| 159 |
+
workflow.add_node("cache_response", cache_response_node)
|
| 160 |
+
workflow.add_node("update_history", update_history_node)
|
| 161 |
+
|
| 162 |
+
# Define ponto de entrada
|
| 163 |
+
workflow.set_entry_point("validate_input")
|
| 164 |
+
|
| 165 |
+
# Fluxo principal
|
| 166 |
+
workflow.add_edge("validate_input", "check_cache")
|
| 167 |
+
|
| 168 |
+
# Condicional para cache hit ou processing
|
| 169 |
+
workflow.add_conditional_edges(
|
| 170 |
+
"check_cache",
|
| 171 |
+
route_after_cache_check,
|
| 172 |
+
{
|
| 173 |
+
"update_history": "update_history",
|
| 174 |
+
"validate_processing": "validate_processing",
|
| 175 |
+
"prepare_context": "prepare_context"
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# Fluxo do Processing Agent
|
| 180 |
+
workflow.add_edge("validate_processing", "process_initial_context")
|
| 181 |
+
workflow.add_edge("process_initial_context", "prepare_context")
|
| 182 |
+
|
| 183 |
+
workflow.add_edge("prepare_context", "get_db_sample")
|
| 184 |
+
workflow.add_edge("get_db_sample", "process_query")
|
| 185 |
+
|
| 186 |
+
# Condicional para gráficos (após AgentSQL)
|
| 187 |
+
workflow.add_conditional_edges(
|
| 188 |
+
"process_query",
|
| 189 |
+
should_generate_graph,
|
| 190 |
+
{
|
| 191 |
+
"graph_selection": "graph_selection",
|
| 192 |
+
"refine_response": "refine_response",
|
| 193 |
+
"cache_response": "cache_response"
|
| 194 |
+
}
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Fluxo dos gráficos
|
| 198 |
+
workflow.add_edge("graph_selection", "graph_generation")
|
| 199 |
+
|
| 200 |
+
# Após geração de gráfico, vai para refinamento ou cache
|
| 201 |
+
workflow.add_conditional_edges(
|
| 202 |
+
"graph_generation",
|
| 203 |
+
should_refine_response,
|
| 204 |
+
{
|
| 205 |
+
"refine_response": "refine_response",
|
| 206 |
+
"cache_response": "cache_response"
|
| 207 |
+
}
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
workflow.add_edge("refine_response", "format_response")
|
| 211 |
+
workflow.add_edge("format_response", "cache_response")
|
| 212 |
+
workflow.add_edge("cache_response", "update_history")
|
| 213 |
+
workflow.add_edge("update_history", END)
|
| 214 |
+
|
| 215 |
+
# Compila o grafo
|
| 216 |
+
memory = MemorySaver()
|
| 217 |
+
self.app = workflow.compile(checkpointer=memory)
|
| 218 |
+
|
| 219 |
+
logging.info("Grafo LangGraph construído com sucesso")
|
| 220 |
+
|
| 221 |
+
except Exception as e:
|
| 222 |
+
logging.error(f"Erro ao construir grafo: {e}")
|
| 223 |
+
raise
|
| 224 |
+
|
| 225 |
+
async def process_query(
|
| 226 |
+
self,
|
| 227 |
+
user_input: str,
|
| 228 |
+
selected_model: str = "GPT-4o-mini",
|
| 229 |
+
advanced_mode: bool = False,
|
| 230 |
+
processing_enabled: bool = False,
|
| 231 |
+
processing_model: str = "GPT-4o-mini",
|
| 232 |
+
thread_id: str = "default"
|
| 233 |
+
) -> Dict[str, Any]:
|
| 234 |
+
"""
|
| 235 |
+
Processa uma query do usuário através do grafo
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
user_input: Entrada do usuário
|
| 239 |
+
selected_model: Modelo LLM selecionado
|
| 240 |
+
advanced_mode: Se deve usar refinamento avançado
|
| 241 |
+
processing_enabled: Se deve usar o Processing Agent
|
| 242 |
+
processing_model: Modelo para o Processing Agent
|
| 243 |
+
thread_id: ID da thread para checkpoint
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
Resultado do processamento
|
| 247 |
+
"""
|
| 248 |
+
try:
|
| 249 |
+
# Verifica se precisa recriar agente SQL com modelo diferente
|
| 250 |
+
current_sql_agent = self.object_manager.get_sql_agent(self.agent_id)
|
| 251 |
+
if current_sql_agent and current_sql_agent.model_name != selected_model:
|
| 252 |
+
logging.info(f"Recriando agente SQL com modelo {selected_model}")
|
| 253 |
+
|
| 254 |
+
# Recupera banco de dados associado ao agente
|
| 255 |
+
db_id = self.object_manager.get_db_id_for_agent(self.agent_id)
|
| 256 |
+
if db_id:
|
| 257 |
+
db = self.object_manager.get_database(db_id)
|
| 258 |
+
if db:
|
| 259 |
+
new_sql_agent = SQLAgentManager(db, selected_model)
|
| 260 |
+
self.agent_id = self.object_manager.store_sql_agent(new_sql_agent, db_id)
|
| 261 |
+
logging.info(f"Agente SQL recriado com sucesso para modelo {selected_model}")
|
| 262 |
+
else:
|
| 263 |
+
logging.error("Banco de dados não encontrado para recriar agente")
|
| 264 |
+
else:
|
| 265 |
+
logging.error("ID do banco de dados não encontrado para o agente")
|
| 266 |
+
|
| 267 |
+
# Log dos parâmetros recebidos
|
| 268 |
+
logging.info(f"[MAIN GRAPH] ===== INICIANDO PROCESSAMENTO DE QUERY =====")
|
| 269 |
+
logging.info(f"[MAIN GRAPH] User input: {user_input}")
|
| 270 |
+
logging.info(f"[MAIN GRAPH] Selected model: {selected_model}")
|
| 271 |
+
logging.info(f"[MAIN GRAPH] Advanced mode: {advanced_mode}")
|
| 272 |
+
logging.info(f"[MAIN GRAPH] Processing enabled: {processing_enabled}")
|
| 273 |
+
logging.info(f"[MAIN GRAPH] Processing model: {processing_model}")
|
| 274 |
+
|
| 275 |
+
# Prepara estado inicial com IDs serializáveis
|
| 276 |
+
initial_state = {
|
| 277 |
+
"user_input": user_input,
|
| 278 |
+
"selected_model": selected_model,
|
| 279 |
+
"response": "",
|
| 280 |
+
"advanced_mode": advanced_mode,
|
| 281 |
+
"execution_time": 0.0,
|
| 282 |
+
"error": None,
|
| 283 |
+
"intermediate_steps": [],
|
| 284 |
+
"db_sample_dict": {},
|
| 285 |
+
# IDs para recuperar objetos não-serializáveis
|
| 286 |
+
"agent_id": self.agent_id,
|
| 287 |
+
"engine_id": self.engine_id,
|
| 288 |
+
"db_id": self.db_id,
|
| 289 |
+
"cache_id": self.cache_id,
|
| 290 |
+
# Campos relacionados a gráficos
|
| 291 |
+
"query_type": "sql_query", # Será atualizado pela detecção
|
| 292 |
+
"sql_query_extracted": None,
|
| 293 |
+
"graph_type": None,
|
| 294 |
+
"graph_data": None,
|
| 295 |
+
"graph_image_id": None,
|
| 296 |
+
"graph_generated": False,
|
| 297 |
+
"graph_error": None,
|
| 298 |
+
# Campos relacionados ao cache
|
| 299 |
+
"cache_hit": False,
|
| 300 |
+
# Campos relacionados ao Processing Agent
|
| 301 |
+
"processing_enabled": processing_enabled,
|
| 302 |
+
"processing_model": processing_model,
|
| 303 |
+
"processing_agent_id": None,
|
| 304 |
+
"suggested_query": None,
|
| 305 |
+
"query_observations": None,
|
| 306 |
+
"processing_result": None,
|
| 307 |
+
"processing_success": False,
|
| 308 |
+
"processing_error": None,
|
| 309 |
+
# Campos relacionados ao refinamento
|
| 310 |
+
"refined": False,
|
| 311 |
+
"refinement_error": None,
|
| 312 |
+
"refinement_quality": None,
|
| 313 |
+
"quality_metrics": None,
|
| 314 |
+
# Campos relacionados ao contexto SQL
|
| 315 |
+
"sql_context": None,
|
| 316 |
+
"sql_result": None
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
# Executa o grafo
|
| 320 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 321 |
+
result = await self.app.ainvoke(initial_state, config=config)
|
| 322 |
+
|
| 323 |
+
logging.info(f"Query processada com sucesso: {user_input[:50]}...")
|
| 324 |
+
return result
|
| 325 |
+
|
| 326 |
+
except Exception as e:
|
| 327 |
+
error_msg = f"Erro ao processar query: {e}"
|
| 328 |
+
logging.error(error_msg)
|
| 329 |
+
return {
|
| 330 |
+
"user_input": user_input,
|
| 331 |
+
"response": error_msg,
|
| 332 |
+
"error": error_msg,
|
| 333 |
+
"execution_time": 0.0
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
async def handle_csv_upload(self, file_path: str) -> Dict[str, Any]:
|
| 337 |
+
"""
|
| 338 |
+
Processa upload de CSV usando nova arquitetura de nós
|
| 339 |
+
|
| 340 |
+
Args:
|
| 341 |
+
file_path: Caminho do arquivo CSV
|
| 342 |
+
|
| 343 |
+
Returns:
|
| 344 |
+
Resultado do upload
|
| 345 |
+
"""
|
| 346 |
+
try:
|
| 347 |
+
# Etapa 1: Processa CSV
|
| 348 |
+
csv_state = {
|
| 349 |
+
"file_path": file_path,
|
| 350 |
+
"success": False,
|
| 351 |
+
"message": "",
|
| 352 |
+
"csv_data_sample": {},
|
| 353 |
+
"column_info": {},
|
| 354 |
+
"processing_stats": {}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
csv_result = await csv_processing_node(csv_state)
|
| 358 |
+
|
| 359 |
+
if not csv_result["success"]:
|
| 360 |
+
return csv_result
|
| 361 |
+
|
| 362 |
+
# Etapa 2: Cria banco de dados
|
| 363 |
+
db_state = csv_result.copy()
|
| 364 |
+
db_result = await create_database_from_dataframe_node(db_state)
|
| 365 |
+
|
| 366 |
+
if not db_result["success"]:
|
| 367 |
+
return db_result
|
| 368 |
+
|
| 369 |
+
# Etapa 3: Atualiza sistema
|
| 370 |
+
if db_result["success"]:
|
| 371 |
+
# Atualiza IDs dos objetos
|
| 372 |
+
self.engine_id = db_result["engine_id"]
|
| 373 |
+
self.db_id = db_result["db_id"]
|
| 374 |
+
|
| 375 |
+
# Cria novo agente SQL
|
| 376 |
+
new_engine = self.object_manager.get_engine(self.engine_id)
|
| 377 |
+
new_db = self.object_manager.get_database(self.db_id)
|
| 378 |
+
new_sql_agent = SQLAgentManager(new_db)
|
| 379 |
+
|
| 380 |
+
# Atualiza agente
|
| 381 |
+
self.agent_id = self.object_manager.store_sql_agent(new_sql_agent, self.db_id)
|
| 382 |
+
|
| 383 |
+
# Limpa cache
|
| 384 |
+
cache_manager = self.object_manager.get_cache_manager(self.cache_id)
|
| 385 |
+
if cache_manager:
|
| 386 |
+
cache_manager.clear_cache()
|
| 387 |
+
|
| 388 |
+
logging.info("[UPLOAD] Sistema atualizado com novo CSV")
|
| 389 |
+
|
| 390 |
+
return db_result
|
| 391 |
+
|
| 392 |
+
except Exception as e:
|
| 393 |
+
error_msg = f"❌ Erro no upload de CSV: {e}"
|
| 394 |
+
logging.error(error_msg)
|
| 395 |
+
return {
|
| 396 |
+
"success": False,
|
| 397 |
+
"message": error_msg
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
async def reset_system(self) -> Dict[str, Any]:
|
| 401 |
+
"""
|
| 402 |
+
Reseta o sistema ao estado inicial
|
| 403 |
+
|
| 404 |
+
Returns:
|
| 405 |
+
Resultado do reset
|
| 406 |
+
"""
|
| 407 |
+
try:
|
| 408 |
+
# Usa nó de reset customizado
|
| 409 |
+
state = {
|
| 410 |
+
"success": False,
|
| 411 |
+
"message": "",
|
| 412 |
+
"engine_id": self.engine_id,
|
| 413 |
+
"agent_id": self.agent_id,
|
| 414 |
+
"cache_id": self.cache_id
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
result = await self.custom_node_manager.execute_node("system_reset", state)
|
| 418 |
+
|
| 419 |
+
# Se reset foi bem-sucedido, atualiza IDs
|
| 420 |
+
if result.get("success"):
|
| 421 |
+
self.engine_id = result.get("engine_id", self.engine_id)
|
| 422 |
+
self.agent_id = result.get("agent_id", self.agent_id)
|
| 423 |
+
# Cache ID permanece o mesmo, apenas é limpo
|
| 424 |
+
|
| 425 |
+
logging.info("[RESET] Sistema resetado com sucesso")
|
| 426 |
+
|
| 427 |
+
return result
|
| 428 |
+
|
| 429 |
+
except Exception as e:
|
| 430 |
+
error_msg = f"❌ Erro ao resetar sistema: {e}"
|
| 431 |
+
logging.error(error_msg)
|
| 432 |
+
return {
|
| 433 |
+
"success": False,
|
| 434 |
+
"message": error_msg
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
def toggle_advanced_mode(self, enabled: bool) -> str:
|
| 438 |
+
"""
|
| 439 |
+
Alterna modo avançado
|
| 440 |
+
|
| 441 |
+
Args:
|
| 442 |
+
enabled: Se deve habilitar modo avançado
|
| 443 |
+
|
| 444 |
+
Returns:
|
| 445 |
+
Mensagem de status
|
| 446 |
+
"""
|
| 447 |
+
message = "Modo avançado ativado." if enabled else "Modo avançado desativado."
|
| 448 |
+
logging.info(f"[MODO AVANÇADO] {'Ativado' if enabled else 'Desativado'}")
|
| 449 |
+
return message
|
| 450 |
+
|
| 451 |
+
def get_history(self) -> list:
|
| 452 |
+
"""
|
| 453 |
+
Retorna histórico de conversas
|
| 454 |
+
|
| 455 |
+
Returns:
|
| 456 |
+
Lista com histórico
|
| 457 |
+
"""
|
| 458 |
+
return self.cache_manager.get_history()
|
| 459 |
+
|
| 460 |
+
def clear_cache(self):
|
| 461 |
+
"""Limpa cache do sistema"""
|
| 462 |
+
self.cache_manager.clear_cache()
|
| 463 |
+
logging.info("Cache limpo")
|
| 464 |
+
|
| 465 |
+
async def get_system_info(self) -> Dict[str, Any]:
|
| 466 |
+
"""
|
| 467 |
+
Obtém informações do sistema
|
| 468 |
+
|
| 469 |
+
Returns:
|
| 470 |
+
Informações do sistema
|
| 471 |
+
"""
|
| 472 |
+
state = {
|
| 473 |
+
"engine": self.engine,
|
| 474 |
+
"sql_agent": self.sql_agent,
|
| 475 |
+
"cache_manager": self.cache_manager
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
result = await self.custom_node_manager.execute_node("system_info", state)
|
| 479 |
+
return result.get("system_info", {})
|
| 480 |
+
|
| 481 |
+
async def validate_system(self) -> Dict[str, Any]:
|
| 482 |
+
"""
|
| 483 |
+
Valida o estado do sistema
|
| 484 |
+
|
| 485 |
+
Returns:
|
| 486 |
+
Resultado da validação
|
| 487 |
+
"""
|
| 488 |
+
state = {
|
| 489 |
+
"engine": self.engine,
|
| 490 |
+
"sql_agent": self.sql_agent,
|
| 491 |
+
"cache_manager": self.cache_manager
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
result = await self.custom_node_manager.execute_node("system_validation", state)
|
| 495 |
+
return result.get("validation", {})
|
| 496 |
+
|
| 497 |
+
def _smart_type_conversion(self, df, sql_types):
|
| 498 |
+
"""
|
| 499 |
+
Conversão inteligente de tipos de dados com suporte a formatos brasileiros
|
| 500 |
+
"""
|
| 501 |
+
import re
|
| 502 |
+
|
| 503 |
+
logging.info("[TYPE_CONVERSION] 🔧 Iniciando conversão inteligente de tipos")
|
| 504 |
+
|
| 505 |
+
for col in df.columns:
|
| 506 |
+
col_data = df[col].dropna() # Remove NaN para análise
|
| 507 |
+
|
| 508 |
+
if len(col_data) == 0:
|
| 509 |
+
continue
|
| 510 |
+
|
| 511 |
+
# Amostra para análise (primeiros 100 valores não-nulos)
|
| 512 |
+
sample = col_data.head(100).astype(str)
|
| 513 |
+
|
| 514 |
+
logging.debug(f"[TYPE_CONVERSION] 📊 Analisando coluna: {col}")
|
| 515 |
+
|
| 516 |
+
# 1. DETECTAR DATAS
|
| 517 |
+
if self._is_date_column(sample):
|
| 518 |
+
try:
|
| 519 |
+
df[col] = self._convert_to_date(df[col])
|
| 520 |
+
sql_types[col] = DateTime
|
| 521 |
+
logging.debug(f"[TYPE_CONVERSION] ✅ {col} → DATETIME")
|
| 522 |
+
continue
|
| 523 |
+
except Exception as e:
|
| 524 |
+
logging.warning(f"[TYPE_CONVERSION] ⚠️ Falha ao converter {col} para data: {e}")
|
| 525 |
+
|
| 526 |
+
# 2. DETECTAR NÚMEROS INTEIROS (PRIORIDADE ALTA)
|
| 527 |
+
if self._is_integer_column(sample):
|
| 528 |
+
try:
|
| 529 |
+
# Converter removendo caracteres não numéricos, mas mantendo negativos
|
| 530 |
+
def clean_integer(value):
|
| 531 |
+
if pd.isna(value):
|
| 532 |
+
return None
|
| 533 |
+
value_str = str(value).strip()
|
| 534 |
+
# Manter apenas dígitos e sinal negativo
|
| 535 |
+
clean_value = ''.join(c for c in value_str if c.isdigit() or c == '-')
|
| 536 |
+
if clean_value and clean_value != '-':
|
| 537 |
+
return int(clean_value)
|
| 538 |
+
return None
|
| 539 |
+
|
| 540 |
+
df[col] = df[col].apply(clean_integer).astype('Int64')
|
| 541 |
+
sql_types[col] = Integer
|
| 542 |
+
logging.debug(f"[TYPE_CONVERSION] ✅ {col} → INTEGER")
|
| 543 |
+
continue
|
| 544 |
+
except Exception as e:
|
| 545 |
+
logging.warning(f"[TYPE_CONVERSION] ⚠️ Falha ao converter {col} para inteiro: {e}")
|
| 546 |
+
|
| 547 |
+
# 3. DETECTAR VALORES MONETÁRIOS
|
| 548 |
+
if self._is_monetary_column(sample):
|
| 549 |
+
try:
|
| 550 |
+
df[col] = self._convert_to_monetary(df[col])
|
| 551 |
+
sql_types[col] = Float
|
| 552 |
+
logging.debug(f"[TYPE_CONVERSION] ✅ {col} → FLOAT (monetário)")
|
| 553 |
+
continue
|
| 554 |
+
except Exception as e:
|
| 555 |
+
logging.warning(f"[TYPE_CONVERSION] ⚠️ Falha ao converter {col} para monetário: {e}")
|
| 556 |
+
|
| 557 |
+
# 4. DETECTAR NÚMEROS DECIMAIS
|
| 558 |
+
if self._is_float_column(sample):
|
| 559 |
+
try:
|
| 560 |
+
df[col] = self._convert_to_float(df[col])
|
| 561 |
+
sql_types[col] = Float
|
| 562 |
+
logging.debug(f"[TYPE_CONVERSION] ✅ {col} → FLOAT")
|
| 563 |
+
continue
|
| 564 |
+
except Exception as e:
|
| 565 |
+
logging.warning(f"[TYPE_CONVERSION] ⚠️ Falha ao converter {col} para float: {e}")
|
| 566 |
+
|
| 567 |
+
# 5. MANTER COMO TEXTO (padrão)
|
| 568 |
+
logging.debug(f"[TYPE_CONVERSION] 📝 {col} → TEXT (padrão)")
|
| 569 |
+
|
| 570 |
+
# Resumo da conversão
|
| 571 |
+
type_summary = {}
|
| 572 |
+
for col, sql_type in sql_types.items():
|
| 573 |
+
type_name = sql_type.__name__ if hasattr(sql_type, '__name__') else str(sql_type).split('.')[-1].replace('>', '')
|
| 574 |
+
if type_name not in type_summary:
|
| 575 |
+
type_summary[type_name] = 0
|
| 576 |
+
type_summary[type_name] += 1
|
| 577 |
+
|
| 578 |
+
summary_text = ", ".join([f"{count} {type_name}" for type_name, count in type_summary.items()])
|
| 579 |
+
logging.info(f"[TYPE_CONVERSION] ✅ Conversão concluída: {summary_text}")
|
| 580 |
+
return df
|
| 581 |
+
|
| 582 |
+
def _is_date_column(self, sample):
|
| 583 |
+
"""Detecta se uma coluna contém datas BASEADO APENAS NOS VALORES"""
|
| 584 |
+
import re
|
| 585 |
+
|
| 586 |
+
# Padrões de data brasileiros e internacionais
|
| 587 |
+
date_patterns = [
|
| 588 |
+
r'^\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{4}$', # DD/MM/YYYY ou DD-MM-YYYY
|
| 589 |
+
r'^\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2}$', # YYYY/MM/DD ou YYYY-MM-DD
|
| 590 |
+
r'^\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2}$', # DD/MM/YY
|
| 591 |
+
]
|
| 592 |
+
|
| 593 |
+
# Verificar se pelo menos 70% dos valores seguem padrão de data
|
| 594 |
+
date_count = 0
|
| 595 |
+
for value in sample:
|
| 596 |
+
if pd.isna(value) or value == '':
|
| 597 |
+
continue
|
| 598 |
+
for pattern in date_patterns:
|
| 599 |
+
if re.match(pattern, str(value).strip()):
|
| 600 |
+
date_count += 1
|
| 601 |
+
break
|
| 602 |
+
|
| 603 |
+
return date_count / len(sample) >= 0.7
|
| 604 |
+
|
| 605 |
+
def _is_monetary_column(self, sample):
|
| 606 |
+
"""Detecta se uma coluna contém valores monetários BASEADO APENAS NOS VALORES"""
|
| 607 |
+
import re
|
| 608 |
+
|
| 609 |
+
# Padrões monetários brasileiros e internacionais
|
| 610 |
+
money_patterns = [
|
| 611 |
+
r'^R\$\s*\d+[,\.]\d{2}$', # R$ 10,50 ou R$ 10.50
|
| 612 |
+
r'^\d+[,\.]\d{2}$', # 10,50 ou 10.50
|
| 613 |
+
r'^R\$\s*\d+$', # R$ 10
|
| 614 |
+
r'^\$\s*\d+[,\.]\d{2}$', # $ 10.50
|
| 615 |
+
r'^\$\s*\d+$', # $ 10
|
| 616 |
+
]
|
| 617 |
+
|
| 618 |
+
# Verificar se pelo menos 60% dos valores seguem padrão monetário
|
| 619 |
+
money_count = 0
|
| 620 |
+
for value in sample:
|
| 621 |
+
if pd.isna(value) or value == '':
|
| 622 |
+
continue
|
| 623 |
+
value_str = str(value).strip()
|
| 624 |
+
for pattern in money_patterns:
|
| 625 |
+
if re.match(pattern, value_str):
|
| 626 |
+
money_count += 1
|
| 627 |
+
break
|
| 628 |
+
|
| 629 |
+
return money_count / len(sample) >= 0.6
|
| 630 |
+
|
| 631 |
+
def _is_integer_column(self, sample):
|
| 632 |
+
"""Detecta se uma coluna contém números inteiros"""
|
| 633 |
+
try:
|
| 634 |
+
# Primeiro, verificar se há vírgulas ou pontos decimais nos valores
|
| 635 |
+
has_decimal_separators = False
|
| 636 |
+
valid_numeric_count = 0
|
| 637 |
+
integer_count = 0
|
| 638 |
+
|
| 639 |
+
for value in sample:
|
| 640 |
+
if pd.isna(value) or value == '':
|
| 641 |
+
continue
|
| 642 |
+
|
| 643 |
+
value_str = str(value).strip()
|
| 644 |
+
|
| 645 |
+
# Se contém vírgula ou ponto seguido de dígitos, é decimal
|
| 646 |
+
if (',' in value_str and any(c.isdigit() for c in value_str.split(',')[-1])) or \
|
| 647 |
+
('.' in value_str and any(c.isdigit() for c in value_str.split('.')[-1])):
|
| 648 |
+
has_decimal_separators = True
|
| 649 |
+
break
|
| 650 |
+
|
| 651 |
+
# Tentar converter para número
|
| 652 |
+
try:
|
| 653 |
+
# Remover espaços e caracteres não numéricos (exceto - para negativos)
|
| 654 |
+
clean_value = ''.join(c for c in value_str if c.isdigit() or c == '-')
|
| 655 |
+
if clean_value and clean_value != '-':
|
| 656 |
+
num_value = int(clean_value)
|
| 657 |
+
valid_numeric_count += 1
|
| 658 |
+
integer_count += 1
|
| 659 |
+
except:
|
| 660 |
+
# Se não conseguir converter para int, tentar float
|
| 661 |
+
try:
|
| 662 |
+
float_value = float(value_str)
|
| 663 |
+
valid_numeric_count += 1
|
| 664 |
+
# Se o float é igual ao int, conta como inteiro
|
| 665 |
+
if float_value == int(float_value):
|
| 666 |
+
integer_count += 1
|
| 667 |
+
except:
|
| 668 |
+
continue
|
| 669 |
+
|
| 670 |
+
# Se encontrou separadores decimais, não é coluna de inteiros
|
| 671 |
+
if has_decimal_separators:
|
| 672 |
+
return False
|
| 673 |
+
|
| 674 |
+
# Verificar se pelo menos 80% são números válidos
|
| 675 |
+
if valid_numeric_count == 0 or valid_numeric_count / len(sample) < 0.8:
|
| 676 |
+
return False
|
| 677 |
+
|
| 678 |
+
# Verificar se pelo menos 95% dos números válidos são inteiros
|
| 679 |
+
return integer_count / valid_numeric_count >= 0.95
|
| 680 |
+
|
| 681 |
+
except Exception as e:
|
| 682 |
+
logging.debug(f"Erro na detecção de inteiros: {e}")
|
| 683 |
+
return False
|
| 684 |
+
|
| 685 |
+
def _is_float_column(self, sample):
|
| 686 |
+
"""Detecta se uma coluna contém números decimais (com vírgula ou ponto)"""
|
| 687 |
+
try:
|
| 688 |
+
has_decimal_values = False
|
| 689 |
+
valid_numeric_count = 0
|
| 690 |
+
|
| 691 |
+
for value in sample:
|
| 692 |
+
if pd.isna(value) or value == '':
|
| 693 |
+
continue
|
| 694 |
+
|
| 695 |
+
value_str = str(value).strip()
|
| 696 |
+
|
| 697 |
+
# Verificar se contém separadores decimais com dígitos após
|
| 698 |
+
if (',' in value_str and any(c.isdigit() for c in value_str.split(',')[-1])) or \
|
| 699 |
+
('.' in value_str and any(c.isdigit() for c in value_str.split('.')[-1])):
|
| 700 |
+
has_decimal_values = True
|
| 701 |
+
|
| 702 |
+
# Tentar converter para numérico (substituindo vírgula por ponto)
|
| 703 |
+
try:
|
| 704 |
+
clean_value = value_str.replace(',', '.')
|
| 705 |
+
float(clean_value)
|
| 706 |
+
valid_numeric_count += 1
|
| 707 |
+
except:
|
| 708 |
+
continue
|
| 709 |
+
|
| 710 |
+
# Só é float se tem separadores decimais E pelo menos 80% são números válidos
|
| 711 |
+
if not has_decimal_values:
|
| 712 |
+
return False
|
| 713 |
+
|
| 714 |
+
return valid_numeric_count / len(sample) >= 0.8
|
| 715 |
+
|
| 716 |
+
except Exception as e:
|
| 717 |
+
logging.debug(f"Erro na detecção de floats: {e}")
|
| 718 |
+
return False
|
| 719 |
+
|
| 720 |
+
def _convert_to_date(self, series):
|
| 721 |
+
"""Converte série para datetime com formatos brasileiros"""
|
| 722 |
+
# Tentar diferentes formatos de data
|
| 723 |
+
date_formats = [
|
| 724 |
+
'%d/%m/%Y', # 31/12/2023
|
| 725 |
+
'%d-%m-%Y', # 31-12-2023
|
| 726 |
+
'%d.%m.%Y', # 31.12.2023
|
| 727 |
+
'%Y-%m-%d', # 2023-12-31
|
| 728 |
+
'%Y/%m/%d', # 2023/12/31
|
| 729 |
+
'%d/%m/%y', # 31/12/23
|
| 730 |
+
]
|
| 731 |
+
|
| 732 |
+
for fmt in date_formats:
|
| 733 |
+
try:
|
| 734 |
+
return pd.to_datetime(series, format=fmt, errors='raise')
|
| 735 |
+
except:
|
| 736 |
+
continue
|
| 737 |
+
|
| 738 |
+
# Se nenhum formato específico funcionou, usar inferência automática
|
| 739 |
+
try:
|
| 740 |
+
return pd.to_datetime(series, dayfirst=True, errors='coerce')
|
| 741 |
+
except:
|
| 742 |
+
raise ValueError("Não foi possível converter para data")
|
| 743 |
+
|
| 744 |
+
def _convert_to_monetary(self, series):
|
| 745 |
+
"""Converte série para valores monetários (float)"""
|
| 746 |
+
def clean_monetary(value):
|
| 747 |
+
if pd.isna(value):
|
| 748 |
+
return None
|
| 749 |
+
|
| 750 |
+
# Converter para string e limpar
|
| 751 |
+
value_str = str(value).strip()
|
| 752 |
+
|
| 753 |
+
# Remover símbolos monetários
|
| 754 |
+
value_str = value_str.replace('R$', '').replace('$', '').strip()
|
| 755 |
+
|
| 756 |
+
# Tratar formato brasileiro (vírgula como decimal)
|
| 757 |
+
if ',' in value_str and '.' in value_str:
|
| 758 |
+
# Formato: 1.234,56 → 1234.56
|
| 759 |
+
value_str = value_str.replace('.', '').replace(',', '.')
|
| 760 |
+
elif ',' in value_str:
|
| 761 |
+
# Formato: 1234,56 → 1234.56
|
| 762 |
+
value_str = value_str.replace(',', '.')
|
| 763 |
+
|
| 764 |
+
try:
|
| 765 |
+
return float(value_str)
|
| 766 |
+
except:
|
| 767 |
+
return None
|
| 768 |
+
|
| 769 |
+
return series.apply(clean_monetary)
|
| 770 |
+
|
| 771 |
+
def _convert_to_float(self, series):
|
| 772 |
+
"""Converte série para float com formato brasileiro"""
|
| 773 |
+
def clean_float(value):
|
| 774 |
+
if pd.isna(value):
|
| 775 |
+
return None
|
| 776 |
+
|
| 777 |
+
value_str = str(value).strip()
|
| 778 |
+
|
| 779 |
+
# Tratar formato brasileiro
|
| 780 |
+
if ',' in value_str:
|
| 781 |
+
value_str = value_str.replace(',', '.')
|
| 782 |
+
|
| 783 |
+
try:
|
| 784 |
+
return float(value_str)
|
| 785 |
+
except:
|
| 786 |
+
return None
|
| 787 |
+
|
| 788 |
+
return series.apply(clean_float)
|
| 789 |
+
|
| 790 |
+
# Instância global do gerenciador
|
| 791 |
+
_graph_manager: Optional[AgentGraphManager] = None
|
| 792 |
+
|
| 793 |
+
def get_graph_manager() -> AgentGraphManager:
|
| 794 |
+
"""
|
| 795 |
+
Retorna instância singleton do gerenciador de grafo
|
| 796 |
+
|
| 797 |
+
Returns:
|
| 798 |
+
AgentGraphManager
|
| 799 |
+
"""
|
| 800 |
+
global _graph_manager
|
| 801 |
+
if _graph_manager is None:
|
| 802 |
+
_graph_manager = AgentGraphManager()
|
| 803 |
+
return _graph_manager
|
| 804 |
+
|
| 805 |
+
async def initialize_graph() -> AgentGraphManager:
|
| 806 |
+
"""
|
| 807 |
+
Inicializa o grafo principal
|
| 808 |
+
|
| 809 |
+
Returns:
|
| 810 |
+
AgentGraphManager inicializado
|
| 811 |
+
"""
|
| 812 |
+
try:
|
| 813 |
+
manager = get_graph_manager()
|
| 814 |
+
|
| 815 |
+
# Valida sistema
|
| 816 |
+
validation = await manager.validate_system()
|
| 817 |
+
if not validation.get("overall_valid", False):
|
| 818 |
+
logging.warning("Sistema não passou na validação completa")
|
| 819 |
+
|
| 820 |
+
logging.info("Grafo principal inicializado e validado")
|
| 821 |
+
return manager
|
| 822 |
+
|
| 823 |
+
except Exception as e:
|
| 824 |
+
logging.error(f"Erro ao inicializar grafo: {e}")
|
| 825 |
+
raise
|
| 826 |
+
|
| 827 |
+
# Classe GraphManager removida - funcionalidade movida para AgentGraphManager
|
nodes/agent_node.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Definições do estado do agente e funções de coordenação geral
|
| 3 |
+
"""
|
| 4 |
+
from typing import Dict, Any, Optional, TypedDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class AgentState(TypedDict):
|
| 8 |
+
"""Estado do agente LangGraph - apenas dados serializáveis"""
|
| 9 |
+
user_input: str
|
| 10 |
+
selected_model: str
|
| 11 |
+
response: str
|
| 12 |
+
advanced_mode: bool
|
| 13 |
+
execution_time: float
|
| 14 |
+
error: Optional[str]
|
| 15 |
+
intermediate_steps: list
|
| 16 |
+
|
| 17 |
+
# Dados serializáveis do banco
|
| 18 |
+
db_sample_dict: dict
|
| 19 |
+
|
| 20 |
+
# IDs para recuperar objetos não-serializáveis
|
| 21 |
+
agent_id: str
|
| 22 |
+
engine_id: str
|
| 23 |
+
cache_id: str
|
| 24 |
+
|
| 25 |
+
# Campos relacionados a gráficos
|
| 26 |
+
query_type: str # 'sql_query', 'sql_query_graphic', 'prediction'
|
| 27 |
+
sql_query_extracted: Optional[str] # Query SQL extraída da resposta do agente
|
| 28 |
+
graph_type: Optional[str] # Tipo de gráfico escolhido pela LLM
|
| 29 |
+
graph_data: Optional[dict] # Dados preparados para o gráfico (serializável)
|
| 30 |
+
graph_image_id: Optional[str] # ID da imagem do gráfico no ObjectManager
|
| 31 |
+
graph_generated: bool # Se o gráfico foi gerado com sucesso
|
| 32 |
+
graph_error: Optional[str] # Erro na geração de gráfico
|
| 33 |
+
|
| 34 |
+
# Campos relacionados ao cache
|
| 35 |
+
cache_hit: bool # Se houve hit no cache
|
| 36 |
+
|
| 37 |
+
# Campos relacionados ao Processing Agent
|
| 38 |
+
processing_enabled: bool # Se o Processing Agent está habilitado
|
| 39 |
+
processing_model: str # Modelo usado no Processing Agent
|
| 40 |
+
processing_agent_id: Optional[str] # ID do Processing Agent no ObjectManager
|
| 41 |
+
suggested_query: Optional[str] # Query SQL sugerida pelo Processing Agent
|
| 42 |
+
query_observations: Optional[str] # Observações sobre a query sugerida
|
| 43 |
+
processing_result: Optional[dict] # Resultado completo do Processing Agent
|
| 44 |
+
processing_success: bool # Se o processamento foi bem-sucedido
|
| 45 |
+
processing_error: Optional[str] # Erro no processamento
|
| 46 |
+
|
| 47 |
+
# Campos relacionados ao refinamento
|
| 48 |
+
refined: bool # Se a resposta foi refinada
|
| 49 |
+
refinement_error: Optional[str] # Erro no refinamento
|
| 50 |
+
refinement_quality: Optional[str] # Qualidade do refinamento
|
| 51 |
+
quality_metrics: Optional[dict] # Métricas de qualidade
|
| 52 |
+
|
| 53 |
+
# Campos relacionados ao contexto SQL
|
| 54 |
+
sql_context: Optional[str] # Contexto preparado para o agente SQL
|
| 55 |
+
sql_result: Optional[dict] # Resultado do agente SQL
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def should_refine_response(state: Dict[str, Any]) -> str:
|
| 59 |
+
"""
|
| 60 |
+
Determina se deve refinar a resposta
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
state: Estado atual
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Nome do próximo nó
|
| 67 |
+
"""
|
| 68 |
+
if state.get("advanced_mode", False) and not state.get("error"):
|
| 69 |
+
return "refine_response"
|
| 70 |
+
else:
|
| 71 |
+
return "cache_response"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def should_generate_graph(state: Dict[str, Any]) -> str:
|
| 75 |
+
"""
|
| 76 |
+
Determina se deve gerar gráfico
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
state: Estado atual
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Nome do próximo nó
|
| 83 |
+
"""
|
| 84 |
+
query_type = state.get("query_type", "")
|
| 85 |
+
|
| 86 |
+
if query_type == "sql_query_graphic" and not state.get("error"):
|
| 87 |
+
return "graph_selection"
|
| 88 |
+
elif state.get("advanced_mode", False) and not state.get("error"):
|
| 89 |
+
return "refine_response"
|
| 90 |
+
else:
|
| 91 |
+
return "cache_response"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def should_use_processing_agent(state: Dict[str, Any]) -> str:
|
| 95 |
+
"""
|
| 96 |
+
Determina se deve usar o Processing Agent
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
state: Estado atual
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Nome do próximo nó
|
| 103 |
+
"""
|
| 104 |
+
if state.get("processing_enabled", False):
|
| 105 |
+
return "validate_processing"
|
| 106 |
+
else:
|
| 107 |
+
return "prepare_context"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def route_after_cache_check(state: Dict[str, Any]) -> str:
|
| 111 |
+
"""
|
| 112 |
+
Roteamento após verificação de cache
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
state: Estado atual
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
Nome do próximo nó
|
| 119 |
+
"""
|
| 120 |
+
import logging
|
| 121 |
+
|
| 122 |
+
cache_hit = state.get("cache_hit", False)
|
| 123 |
+
processing_enabled = state.get("processing_enabled", False)
|
| 124 |
+
|
| 125 |
+
logging.info(f"[ROUTING] Cache hit: {cache_hit}")
|
| 126 |
+
logging.info(f"[ROUTING] Processing enabled: {processing_enabled}")
|
| 127 |
+
|
| 128 |
+
if cache_hit:
|
| 129 |
+
logging.info("[ROUTING] Direcionando para update_history (cache hit)")
|
| 130 |
+
return "update_history"
|
| 131 |
+
elif processing_enabled:
|
| 132 |
+
logging.info("[ROUTING] Direcionando para validate_processing (processing habilitado)")
|
| 133 |
+
return "validate_processing"
|
| 134 |
+
else:
|
| 135 |
+
logging.info("[ROUTING] Direcionando para prepare_context (fluxo normal)")
|
| 136 |
+
return "prepare_context"
|
nodes/cache_node.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para gerenciamento de cache e histórico
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
|
| 7 |
+
from utils.object_manager import get_object_manager
|
| 8 |
+
|
| 9 |
+
async def update_history_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 10 |
+
"""
|
| 11 |
+
Nó para atualizar histórico e logs
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
state: Estado atual do agente
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
Estado atualizado
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
obj_manager = get_object_manager()
|
| 21 |
+
cache_id = state.get("cache_id")
|
| 22 |
+
|
| 23 |
+
if not cache_id:
|
| 24 |
+
logging.warning("[HISTORY] ID do cache não encontrado")
|
| 25 |
+
return state
|
| 26 |
+
|
| 27 |
+
cache_manager = obj_manager.get_cache_manager(cache_id)
|
| 28 |
+
if not cache_manager:
|
| 29 |
+
logging.warning("[HISTORY] Cache manager não encontrado")
|
| 30 |
+
return state
|
| 31 |
+
|
| 32 |
+
# Adiciona ao histórico de logs
|
| 33 |
+
history_entry = {
|
| 34 |
+
"Modelo AgentSQL": state.get("selected_model", ""),
|
| 35 |
+
"Pergunta": state.get("user_input", ""),
|
| 36 |
+
"Resposta": state.get("response", ""),
|
| 37 |
+
"Tempo de Resposta (s)": round(state.get("execution_time", 0.0), 2),
|
| 38 |
+
"Modo Avançado": state.get("advanced_mode", False),
|
| 39 |
+
"Refinado": state.get("refined", False),
|
| 40 |
+
"Erro": state.get("error"),
|
| 41 |
+
"Tipo de Query": state.get("query_type", "sql_query")
|
| 42 |
+
}
|
| 43 |
+
cache_manager.add_to_history(history_entry)
|
| 44 |
+
|
| 45 |
+
# Atualiza histórico recente
|
| 46 |
+
cache_manager.update_recent_history(
|
| 47 |
+
state.get("user_input", ""),
|
| 48 |
+
state.get("response", "")
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
state["history_updated"] = True
|
| 52 |
+
logging.info("[HISTORY] Histórico atualizado")
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
error_msg = f"Erro ao atualizar histórico: {e}"
|
| 56 |
+
logging.error(f"[HISTORY] {error_msg}")
|
| 57 |
+
state["history_error"] = error_msg
|
| 58 |
+
|
| 59 |
+
return state
|
| 60 |
+
|
| 61 |
+
async def cache_response_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 62 |
+
"""
|
| 63 |
+
Nó para armazenar resposta no cache
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
state: Estado com resposta a ser cacheada
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Estado atualizado
|
| 70 |
+
"""
|
| 71 |
+
try:
|
| 72 |
+
obj_manager = get_object_manager()
|
| 73 |
+
cache_id = state.get("cache_id")
|
| 74 |
+
|
| 75 |
+
if not cache_id:
|
| 76 |
+
logging.warning("[CACHE] ID do cache não encontrado")
|
| 77 |
+
return state
|
| 78 |
+
|
| 79 |
+
cache_manager = obj_manager.get_cache_manager(cache_id)
|
| 80 |
+
if not cache_manager:
|
| 81 |
+
logging.warning("[CACHE] Cache manager não encontrado")
|
| 82 |
+
return state
|
| 83 |
+
|
| 84 |
+
user_input = state.get("user_input", "")
|
| 85 |
+
response = state.get("response", "")
|
| 86 |
+
|
| 87 |
+
if user_input and response and not state.get("error"):
|
| 88 |
+
cache_manager.cache_response(user_input, response)
|
| 89 |
+
state["cached"] = True
|
| 90 |
+
logging.info(f"[CACHE] Resposta cacheada para: {user_input[:50]}...")
|
| 91 |
+
else:
|
| 92 |
+
state["cached"] = False
|
| 93 |
+
logging.info("[CACHE] Resposta não cacheada (erro ou dados insuficientes)")
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
error_msg = f"Erro ao cachear resposta: {e}"
|
| 97 |
+
logging.error(f"[CACHE] {error_msg}")
|
| 98 |
+
state["cache_error"] = error_msg
|
| 99 |
+
|
| 100 |
+
return state
|
| 101 |
+
|
| 102 |
+
async def get_cache_stats_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 103 |
+
"""
|
| 104 |
+
Nó para obter estatísticas do cache
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
state: Estado atual
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
Estado com estatísticas do cache
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
obj_manager = get_object_manager()
|
| 114 |
+
cache_id = state.get("cache_id")
|
| 115 |
+
|
| 116 |
+
if not cache_id:
|
| 117 |
+
state["cache_stats"] = {}
|
| 118 |
+
return state
|
| 119 |
+
|
| 120 |
+
cache_manager = obj_manager.get_cache_manager(cache_id)
|
| 121 |
+
if not cache_manager:
|
| 122 |
+
state["cache_stats"] = {}
|
| 123 |
+
return state
|
| 124 |
+
|
| 125 |
+
# Coleta estatísticas
|
| 126 |
+
cache_stats = {
|
| 127 |
+
"cached_queries": len(cache_manager.query_cache),
|
| 128 |
+
"history_entries": len(cache_manager.history_log),
|
| 129 |
+
"recent_history_size": len(cache_manager.recent_history),
|
| 130 |
+
"cache_hit_rate": 0.0 # Seria calculado com mais dados históricos
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
# Calcula taxa de acerto aproximada
|
| 134 |
+
if cache_stats["history_entries"] > 0:
|
| 135 |
+
# Estimativa simples baseada em queries repetidas
|
| 136 |
+
unique_queries = len(set(entry.get("Pergunta", "") for entry in cache_manager.history_log))
|
| 137 |
+
if unique_queries > 0:
|
| 138 |
+
cache_stats["cache_hit_rate"] = max(0, 1 - (unique_queries / cache_stats["history_entries"]))
|
| 139 |
+
|
| 140 |
+
state["cache_stats"] = cache_stats
|
| 141 |
+
logging.info(f"[CACHE] Estatísticas coletadas: {cache_stats}")
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
error_msg = f"Erro ao obter estatísticas do cache: {e}"
|
| 145 |
+
logging.error(f"[CACHE] {error_msg}")
|
| 146 |
+
state["cache_stats"] = {}
|
| 147 |
+
|
| 148 |
+
return state
|
| 149 |
+
|
| 150 |
+
async def clear_cache_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 151 |
+
"""
|
| 152 |
+
Nó para limpar cache
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
state: Estado atual
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
Estado atualizado
|
| 159 |
+
"""
|
| 160 |
+
try:
|
| 161 |
+
obj_manager = get_object_manager()
|
| 162 |
+
cache_id = state.get("cache_id")
|
| 163 |
+
|
| 164 |
+
if not cache_id:
|
| 165 |
+
state["cache_cleared"] = False
|
| 166 |
+
return state
|
| 167 |
+
|
| 168 |
+
cache_manager = obj_manager.get_cache_manager(cache_id)
|
| 169 |
+
if not cache_manager:
|
| 170 |
+
state["cache_cleared"] = False
|
| 171 |
+
return state
|
| 172 |
+
|
| 173 |
+
# Limpa cache
|
| 174 |
+
cache_manager.clear_cache()
|
| 175 |
+
state["cache_cleared"] = True
|
| 176 |
+
|
| 177 |
+
logging.info("[CACHE] Cache limpo")
|
| 178 |
+
|
| 179 |
+
except Exception as e:
|
| 180 |
+
error_msg = f"Erro ao limpar cache: {e}"
|
| 181 |
+
logging.error(f"[CACHE] {error_msg}")
|
| 182 |
+
state["cache_cleared"] = False
|
| 183 |
+
state["cache_error"] = error_msg
|
| 184 |
+
|
| 185 |
+
return state
|
| 186 |
+
|
| 187 |
+
async def check_cache_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 188 |
+
"""
|
| 189 |
+
Nó para verificar se existe resposta em cache
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
state: Estado com consulta do usuário
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
Estado com resultado da verificação de cache
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
obj_manager = get_object_manager()
|
| 199 |
+
cache_id = state.get("cache_id")
|
| 200 |
+
user_input = state.get("user_input", "")
|
| 201 |
+
|
| 202 |
+
if not cache_id or not user_input:
|
| 203 |
+
state["cache_hit"] = False
|
| 204 |
+
return state
|
| 205 |
+
|
| 206 |
+
cache_manager = obj_manager.get_cache_manager(cache_id)
|
| 207 |
+
if not cache_manager:
|
| 208 |
+
state["cache_hit"] = False
|
| 209 |
+
return state
|
| 210 |
+
|
| 211 |
+
# Verifica cache
|
| 212 |
+
cached_response = cache_manager.get_cached_response(user_input)
|
| 213 |
+
|
| 214 |
+
if cached_response:
|
| 215 |
+
state["cache_hit"] = True
|
| 216 |
+
state["response"] = cached_response
|
| 217 |
+
state["execution_time"] = 0.0
|
| 218 |
+
state["error"] = None
|
| 219 |
+
logging.info(f"[CACHE] Hit para: {user_input[:50]}...")
|
| 220 |
+
else:
|
| 221 |
+
state["cache_hit"] = False
|
| 222 |
+
logging.info(f"[CACHE] Miss para: {user_input[:50]}...")
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
error_msg = f"Erro ao verificar cache: {e}"
|
| 226 |
+
logging.error(f"[CACHE] {error_msg}")
|
| 227 |
+
state["cache_hit"] = False
|
| 228 |
+
state["cache_error"] = error_msg
|
| 229 |
+
|
| 230 |
+
return state
|
nodes/csv_processing_node.py
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para processamento de arquivos CSV
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import logging
|
| 7 |
+
import time
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import numpy as np
|
| 10 |
+
from typing import Dict, Any, TypedDict, List, Optional
|
| 11 |
+
from sqlalchemy.types import DateTime, Integer, Float, String, Boolean
|
| 12 |
+
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
|
| 13 |
+
import multiprocessing as mp
|
| 14 |
+
|
| 15 |
+
from utils.config import UPLOADED_CSV_PATH
|
| 16 |
+
from utils.object_manager import get_object_manager
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
def analyze_numeric_column(sample_values: pd.Series) -> Dict[str, Any]:
|
| 20 |
+
"""
|
| 21 |
+
Análise otimizada para detectar se coluna é numérica
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
sample_values: Amostra dos valores da coluna
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Dicionário com análise numérica
|
| 28 |
+
"""
|
| 29 |
+
analysis = {
|
| 30 |
+
"is_numeric": False,
|
| 31 |
+
"is_integer": False,
|
| 32 |
+
"numeric_ratio": 0.0,
|
| 33 |
+
"has_decimals": False
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if len(sample_values) == 0:
|
| 37 |
+
return analysis
|
| 38 |
+
|
| 39 |
+
# Converte para string e limpa valores
|
| 40 |
+
str_values = sample_values.astype(str).str.strip()
|
| 41 |
+
|
| 42 |
+
# Remove valores vazios e nulos
|
| 43 |
+
clean_values = str_values[
|
| 44 |
+
~str_values.isin(['', 'nan', 'null', 'none', '-', 'NaN', 'NULL'])
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
if len(clean_values) == 0:
|
| 48 |
+
return analysis
|
| 49 |
+
|
| 50 |
+
# Tenta conversão numérica vetorizada
|
| 51 |
+
try:
|
| 52 |
+
# Substitui vírgulas por pontos para formato brasileiro
|
| 53 |
+
numeric_values = clean_values.str.replace(',', '.', regex=False)
|
| 54 |
+
|
| 55 |
+
# Tenta conversão para float
|
| 56 |
+
converted = pd.to_numeric(numeric_values, errors='coerce')
|
| 57 |
+
|
| 58 |
+
# Conta valores válidos
|
| 59 |
+
valid_count = converted.notna().sum()
|
| 60 |
+
total_count = len(clean_values)
|
| 61 |
+
|
| 62 |
+
analysis["numeric_ratio"] = valid_count / total_count if total_count > 0 else 0
|
| 63 |
+
|
| 64 |
+
# Se mais de 80% são números válidos, considera numérico
|
| 65 |
+
if analysis["numeric_ratio"] > 0.8:
|
| 66 |
+
analysis["is_numeric"] = True
|
| 67 |
+
|
| 68 |
+
# Verifica se são inteiros
|
| 69 |
+
valid_numbers = converted.dropna()
|
| 70 |
+
if len(valid_numbers) > 0:
|
| 71 |
+
# Verifica se todos os números válidos são inteiros
|
| 72 |
+
analysis["is_integer"] = all(
|
| 73 |
+
float(x).is_integer() for x in valid_numbers
|
| 74 |
+
if not pd.isna(x) and abs(x) < 1e15 # Evita overflow
|
| 75 |
+
)
|
| 76 |
+
analysis["has_decimals"] = not analysis["is_integer"]
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logging.debug(f"Erro na análise numérica: {e}")
|
| 80 |
+
analysis["is_numeric"] = False
|
| 81 |
+
|
| 82 |
+
return analysis
|
| 83 |
+
|
| 84 |
+
def detect_date_format(date_string: str) -> str:
|
| 85 |
+
"""
|
| 86 |
+
Detecta o formato mais provável de uma string de data
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
date_string: String para analisar
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
'iso', 'american', 'brazilian' ou 'auto'
|
| 93 |
+
"""
|
| 94 |
+
date_str = str(date_string).strip()
|
| 95 |
+
|
| 96 |
+
# Formato ISO (YYYY-MM-DD ou YYYY/MM/DD)
|
| 97 |
+
if len(date_str) >= 10 and date_str[4] in ['-', '/', '.'] and date_str[7] in ['-', '/', '.']:
|
| 98 |
+
if date_str[:4].isdigit() and int(date_str[:4]) > 1900:
|
| 99 |
+
return 'iso'
|
| 100 |
+
|
| 101 |
+
# Verifica se pode ser formato americano (MM/DD/YYYY)
|
| 102 |
+
if '/' in date_str:
|
| 103 |
+
parts = date_str.split('/')
|
| 104 |
+
if len(parts) == 3:
|
| 105 |
+
try:
|
| 106 |
+
month, day, year = int(parts[0]), int(parts[1]), int(parts[2])
|
| 107 |
+
# Se o primeiro número é > 12, provavelmente é DD/MM/YYYY
|
| 108 |
+
if month > 12:
|
| 109 |
+
return 'brazilian'
|
| 110 |
+
# Se o segundo número é > 12, provavelmente é MM/DD/YYYY
|
| 111 |
+
elif day > 12:
|
| 112 |
+
return 'american'
|
| 113 |
+
# Se ambos <= 12, é ambíguo, assume brasileiro por padrão
|
| 114 |
+
else:
|
| 115 |
+
return 'brazilian'
|
| 116 |
+
except:
|
| 117 |
+
pass
|
| 118 |
+
|
| 119 |
+
# Formato brasileiro por padrão (DD/MM/YYYY, DD-MM-YYYY, DD.MM.YYYY)
|
| 120 |
+
return 'brazilian'
|
| 121 |
+
|
| 122 |
+
def smart_date_conversion(date_string: str):
|
| 123 |
+
"""
|
| 124 |
+
Converte string para data usando detecção inteligente de formato
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
date_string: String da data
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Timestamp do pandas ou levanta exceção
|
| 131 |
+
"""
|
| 132 |
+
format_type = detect_date_format(date_string)
|
| 133 |
+
|
| 134 |
+
if format_type == 'iso':
|
| 135 |
+
return pd.to_datetime(date_string, errors='raise')
|
| 136 |
+
elif format_type == 'american':
|
| 137 |
+
return pd.to_datetime(date_string, format='%m/%d/%Y', errors='raise')
|
| 138 |
+
elif format_type == 'brazilian':
|
| 139 |
+
return pd.to_datetime(date_string, dayfirst=True, errors='raise')
|
| 140 |
+
else:
|
| 141 |
+
# Fallback para detecção automática
|
| 142 |
+
return pd.to_datetime(date_string, errors='raise')
|
| 143 |
+
|
| 144 |
+
async def process_dates_advanced(series: pd.Series) -> pd.Series:
|
| 145 |
+
"""
|
| 146 |
+
Processa datas com múltiplos formatos de forma robusta
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
series: Série pandas com datas em formato texto
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Série com datas convertidas para datetime
|
| 153 |
+
"""
|
| 154 |
+
# Formatos de data para tentar em ordem de prioridade
|
| 155 |
+
date_formats = [
|
| 156 |
+
'%d/%m/%Y', # 01/12/2024
|
| 157 |
+
'%d-%m-%Y', # 01-12-2024
|
| 158 |
+
'%Y-%m-%d', # 2024-12-01
|
| 159 |
+
'%d/%m/%y', # 01/12/24
|
| 160 |
+
'%d-%m-%y', # 01-12-24
|
| 161 |
+
'%Y/%m/%d', # 2024/12/01
|
| 162 |
+
'%d.%m.%Y', # 01.12.2024
|
| 163 |
+
'%Y.%m.%d', # 2024.12.01
|
| 164 |
+
'%d/%m/%Y %H:%M:%S', # 01/12/2024 14:30:00
|
| 165 |
+
'%Y-%m-%d %H:%M:%S', # 2024-12-01 14:30:00
|
| 166 |
+
]
|
| 167 |
+
|
| 168 |
+
result_series = pd.Series(index=series.index, dtype='datetime64[ns]')
|
| 169 |
+
|
| 170 |
+
for idx, value in series.items():
|
| 171 |
+
if pd.isna(value) or str(value).strip() in ['', 'nan', 'null', 'none', '-']:
|
| 172 |
+
result_series[idx] = pd.NaT
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
value_str = str(value).strip()
|
| 176 |
+
converted = False
|
| 177 |
+
|
| 178 |
+
# Tenta conversão automática com detecção inteligente de formato
|
| 179 |
+
try:
|
| 180 |
+
result_series[idx] = smart_date_conversion(value_str)
|
| 181 |
+
converted = True
|
| 182 |
+
except:
|
| 183 |
+
pass
|
| 184 |
+
|
| 185 |
+
# Se não funcionou, tenta formatos específicos
|
| 186 |
+
if not converted:
|
| 187 |
+
for fmt in date_formats:
|
| 188 |
+
try:
|
| 189 |
+
result_series[idx] = pd.to_datetime(value_str, format=fmt, errors='raise')
|
| 190 |
+
converted = True
|
| 191 |
+
break
|
| 192 |
+
except:
|
| 193 |
+
continue
|
| 194 |
+
|
| 195 |
+
# Se ainda não converteu, marca como NaT
|
| 196 |
+
if not converted:
|
| 197 |
+
result_series[idx] = pd.NaT
|
| 198 |
+
logging.warning(f"Não foi possível converter '{value_str}' para data")
|
| 199 |
+
|
| 200 |
+
return result_series
|
| 201 |
+
|
| 202 |
+
class CSVProcessingState(TypedDict):
|
| 203 |
+
"""Estado para processamento de CSV"""
|
| 204 |
+
file_path: str
|
| 205 |
+
success: bool
|
| 206 |
+
message: str
|
| 207 |
+
csv_data_sample: dict
|
| 208 |
+
column_info: dict
|
| 209 |
+
processing_stats: dict
|
| 210 |
+
|
| 211 |
+
async def detect_column_types(df: pd.DataFrame, sample_size: int = 1000) -> Dict[str, Any]:
|
| 212 |
+
"""
|
| 213 |
+
Detecta automaticamente os tipos de colunas de forma genérica e otimizada
|
| 214 |
+
|
| 215 |
+
Args:
|
| 216 |
+
df: DataFrame do pandas
|
| 217 |
+
sample_size: Número de linhas para amostragem (otimização)
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
Dicionário com informações dos tipos detectados
|
| 221 |
+
"""
|
| 222 |
+
column_info = {
|
| 223 |
+
"detected_types": {},
|
| 224 |
+
"sql_types": {},
|
| 225 |
+
"date_columns": [],
|
| 226 |
+
"numeric_columns": [],
|
| 227 |
+
"text_columns": [],
|
| 228 |
+
"processing_rules": {}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
# Usa amostra para otimizar performance em datasets grandes
|
| 232 |
+
sample_df = df.sample(n=min(sample_size, len(df)), random_state=42) if len(df) > sample_size else df
|
| 233 |
+
logging.info(f"[OPTIMIZATION] Usando amostra de {len(sample_df)} linhas para detecção de tipos")
|
| 234 |
+
|
| 235 |
+
for col in df.columns:
|
| 236 |
+
# Detecta tipo original
|
| 237 |
+
original_type = str(df[col].dtype)
|
| 238 |
+
column_info["detected_types"][col] = original_type
|
| 239 |
+
|
| 240 |
+
# Usa amostra para análise
|
| 241 |
+
sample_col = sample_df[col] if col in sample_df.columns else df[col]
|
| 242 |
+
|
| 243 |
+
# Detecta números já convertidos pelo pandas
|
| 244 |
+
if sample_col.dtype in ['int64', 'Int64', 'float64', 'Float64']:
|
| 245 |
+
if 'int' in str(sample_col.dtype).lower():
|
| 246 |
+
column_info["numeric_columns"].append(col)
|
| 247 |
+
column_info["sql_types"][col] = Integer()
|
| 248 |
+
column_info["processing_rules"][col] = "keep_as_int"
|
| 249 |
+
else:
|
| 250 |
+
column_info["numeric_columns"].append(col)
|
| 251 |
+
column_info["sql_types"][col] = Float()
|
| 252 |
+
column_info["processing_rules"][col] = "keep_as_float"
|
| 253 |
+
continue
|
| 254 |
+
|
| 255 |
+
# Tenta detectar datas de forma mais robusta
|
| 256 |
+
if sample_col.dtype == 'object':
|
| 257 |
+
# Tenta detectar datas com múltiplos formatos
|
| 258 |
+
sample_values = sample_col.dropna().head(20)
|
| 259 |
+
date_success_count = 0
|
| 260 |
+
|
| 261 |
+
# Formatos de data comuns para testar
|
| 262 |
+
date_formats = [
|
| 263 |
+
'%d/%m/%Y', # 01/12/2024
|
| 264 |
+
'%d-%m-%Y', # 01-12-2024
|
| 265 |
+
'%Y-%m-%d', # 2024-12-01
|
| 266 |
+
'%d/%m/%y', # 01/12/24
|
| 267 |
+
'%d-%m-%y', # 01-12-24
|
| 268 |
+
'%Y/%m/%d', # 2024/12/01
|
| 269 |
+
'%d.%m.%Y', # 01.12.2024
|
| 270 |
+
'%Y.%m.%d', # 2024.12.01
|
| 271 |
+
]
|
| 272 |
+
|
| 273 |
+
for val in sample_values:
|
| 274 |
+
val_str = str(val).strip()
|
| 275 |
+
if not val_str or val_str.lower() in ['nan', 'null', 'none', '-']:
|
| 276 |
+
continue
|
| 277 |
+
|
| 278 |
+
# Tenta conversão automática com detecção inteligente
|
| 279 |
+
try:
|
| 280 |
+
smart_date_conversion(val_str)
|
| 281 |
+
date_success_count += 1
|
| 282 |
+
continue
|
| 283 |
+
except:
|
| 284 |
+
pass
|
| 285 |
+
|
| 286 |
+
# Tenta formatos específicos
|
| 287 |
+
for fmt in date_formats:
|
| 288 |
+
try:
|
| 289 |
+
pd.to_datetime(val_str, format=fmt, errors='raise')
|
| 290 |
+
date_success_count += 1
|
| 291 |
+
break
|
| 292 |
+
except:
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
# Se mais de 70% dos valores são datas válidas, considera como coluna de data
|
| 296 |
+
if len(sample_values) > 0 and date_success_count / len(sample_values) > 0.7:
|
| 297 |
+
column_info["date_columns"].append(col)
|
| 298 |
+
column_info["sql_types"][col] = DateTime()
|
| 299 |
+
column_info["processing_rules"][col] = "parse_dates_advanced"
|
| 300 |
+
continue
|
| 301 |
+
|
| 302 |
+
# Tenta detectar números em colunas de texto (otimizado)
|
| 303 |
+
elif sample_col.dtype == 'object':
|
| 304 |
+
# Análise otimizada de números em texto
|
| 305 |
+
sample_values = sample_col.dropna().head(50) # Aumenta amostra para melhor precisão
|
| 306 |
+
|
| 307 |
+
if len(sample_values) == 0:
|
| 308 |
+
column_info["text_columns"].append(col)
|
| 309 |
+
column_info["sql_types"][col] = String()
|
| 310 |
+
column_info["processing_rules"][col] = "keep_as_text"
|
| 311 |
+
continue
|
| 312 |
+
|
| 313 |
+
# Análise vetorizada para performance
|
| 314 |
+
numeric_analysis = analyze_numeric_column(sample_values)
|
| 315 |
+
|
| 316 |
+
if numeric_analysis["is_numeric"]:
|
| 317 |
+
if numeric_analysis["is_integer"]:
|
| 318 |
+
column_info["numeric_columns"].append(col)
|
| 319 |
+
column_info["sql_types"][col] = Integer()
|
| 320 |
+
column_info["processing_rules"][col] = "convert_text_to_int_safe"
|
| 321 |
+
else:
|
| 322 |
+
column_info["numeric_columns"].append(col)
|
| 323 |
+
column_info["sql_types"][col] = Float()
|
| 324 |
+
column_info["processing_rules"][col] = "convert_text_to_float_safe"
|
| 325 |
+
else:
|
| 326 |
+
# Mantém como texto
|
| 327 |
+
column_info["text_columns"].append(col)
|
| 328 |
+
column_info["sql_types"][col] = String()
|
| 329 |
+
column_info["processing_rules"][col] = "keep_as_text"
|
| 330 |
+
|
| 331 |
+
# Tenta detectar números em colunas de texto
|
| 332 |
+
elif df[col].dtype == 'object':
|
| 333 |
+
# Verifica se pode ser convertido para número
|
| 334 |
+
sample_values = df[col].dropna().head(20)
|
| 335 |
+
numeric_count = 0
|
| 336 |
+
|
| 337 |
+
for val in sample_values:
|
| 338 |
+
try:
|
| 339 |
+
# Remove caracteres comuns e tenta converter
|
| 340 |
+
clean_val = str(val).replace(',', '.').replace('-', '').strip()
|
| 341 |
+
if clean_val:
|
| 342 |
+
float(clean_val)
|
| 343 |
+
numeric_count += 1
|
| 344 |
+
except:
|
| 345 |
+
pass
|
| 346 |
+
|
| 347 |
+
# Se mais de 70% são números, trata como numérico
|
| 348 |
+
if len(sample_values) > 0 and numeric_count / len(sample_values) > 0.7:
|
| 349 |
+
# Verifica se são inteiros ou floats
|
| 350 |
+
has_decimal = any('.' in str(val) or ',' in str(val) for val in sample_values)
|
| 351 |
+
if has_decimal:
|
| 352 |
+
column_info["numeric_columns"].append(col)
|
| 353 |
+
column_info["sql_types"][col] = Float()
|
| 354 |
+
column_info["processing_rules"][col] = "convert_text_to_float"
|
| 355 |
+
else:
|
| 356 |
+
column_info["numeric_columns"].append(col)
|
| 357 |
+
column_info["sql_types"][col] = Integer()
|
| 358 |
+
column_info["processing_rules"][col] = "convert_text_to_int"
|
| 359 |
+
else:
|
| 360 |
+
# Mantém como texto
|
| 361 |
+
column_info["text_columns"].append(col)
|
| 362 |
+
column_info["sql_types"][col] = String()
|
| 363 |
+
column_info["processing_rules"][col] = "keep_as_text"
|
| 364 |
+
else:
|
| 365 |
+
# Outros tipos mantém como texto
|
| 366 |
+
column_info["text_columns"].append(col)
|
| 367 |
+
column_info["sql_types"][col] = String()
|
| 368 |
+
column_info["processing_rules"][col] = "keep_as_text"
|
| 369 |
+
|
| 370 |
+
return column_info
|
| 371 |
+
|
| 372 |
+
async def process_dataframe_generic(df: pd.DataFrame, column_info: Dict[str, Any]) -> pd.DataFrame:
|
| 373 |
+
"""
|
| 374 |
+
Processa DataFrame com OTIMIZAÇÕES EXTREMAS para performance máxima
|
| 375 |
+
|
| 376 |
+
Args:
|
| 377 |
+
df: DataFrame original
|
| 378 |
+
column_info: Informações dos tipos detectados
|
| 379 |
+
|
| 380 |
+
Returns:
|
| 381 |
+
DataFrame processado
|
| 382 |
+
"""
|
| 383 |
+
logging.info(f"[ULTRA_OPTIMIZATION] Iniciando processamento ULTRA-OTIMIZADO de {len(df)} linhas")
|
| 384 |
+
start_time = time.time()
|
| 385 |
+
|
| 386 |
+
# OTIMIZAÇÃO 1: Evita cópia desnecessária - modifica in-place quando possível
|
| 387 |
+
processed_df = df
|
| 388 |
+
|
| 389 |
+
# OTIMIZAÇÃO 2: Agrupa colunas por tipo de processamento
|
| 390 |
+
processing_groups = {
|
| 391 |
+
'dates': [],
|
| 392 |
+
'keep_numeric': [],
|
| 393 |
+
'convert_numeric': [],
|
| 394 |
+
'text': []
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
for col, rule in column_info["processing_rules"].items():
|
| 398 |
+
if col not in processed_df.columns:
|
| 399 |
+
continue
|
| 400 |
+
|
| 401 |
+
if 'date' in rule:
|
| 402 |
+
processing_groups['dates'].append((col, rule))
|
| 403 |
+
elif 'keep_as' in rule:
|
| 404 |
+
processing_groups['keep_numeric'].append((col, rule))
|
| 405 |
+
elif 'convert' in rule:
|
| 406 |
+
processing_groups['convert_numeric'].append((col, rule))
|
| 407 |
+
else:
|
| 408 |
+
processing_groups['text'].append((col, rule))
|
| 409 |
+
|
| 410 |
+
# OTIMIZAÇÃO 3: Processamento paralelo por grupos
|
| 411 |
+
await process_groups_parallel(processed_df, processing_groups)
|
| 412 |
+
|
| 413 |
+
total_time = time.time() - start_time
|
| 414 |
+
logging.info(f"[ULTRA_OPTIMIZATION] Processamento ULTRA-OTIMIZADO concluído em {total_time:.2f}s")
|
| 415 |
+
|
| 416 |
+
return processed_df
|
| 417 |
+
|
| 418 |
+
async def process_groups_parallel(df: pd.DataFrame, groups: Dict[str, List]):
|
| 419 |
+
"""
|
| 420 |
+
Processa grupos de colunas em paralelo para máxima performance
|
| 421 |
+
"""
|
| 422 |
+
tasks = []
|
| 423 |
+
|
| 424 |
+
# Processa cada grupo
|
| 425 |
+
for group_name, columns in groups.items():
|
| 426 |
+
if not columns:
|
| 427 |
+
continue
|
| 428 |
+
|
| 429 |
+
if group_name == 'dates':
|
| 430 |
+
tasks.append(process_date_columns_batch(df, columns))
|
| 431 |
+
elif group_name == 'keep_numeric':
|
| 432 |
+
tasks.append(process_keep_numeric_batch(df, columns))
|
| 433 |
+
elif group_name == 'convert_numeric':
|
| 434 |
+
tasks.append(process_convert_numeric_batch(df, columns))
|
| 435 |
+
# text não precisa processamento
|
| 436 |
+
|
| 437 |
+
# Executa todos os grupos em paralelo
|
| 438 |
+
if tasks:
|
| 439 |
+
import asyncio
|
| 440 |
+
await asyncio.gather(*tasks)
|
| 441 |
+
|
| 442 |
+
async def process_date_columns_batch(df: pd.DataFrame, date_columns: List[tuple]):
|
| 443 |
+
"""Processa colunas de data em lote"""
|
| 444 |
+
for col, rule in date_columns:
|
| 445 |
+
try:
|
| 446 |
+
if rule == "parse_dates_advanced":
|
| 447 |
+
# OTIMIZAÇÃO: Processamento vetorizado de datas
|
| 448 |
+
df[col] = process_dates_vectorized(df[col])
|
| 449 |
+
else:
|
| 450 |
+
df[col] = pd.to_datetime(df[col], dayfirst=True, errors='coerce')
|
| 451 |
+
except Exception as e:
|
| 452 |
+
logging.warning(f"Erro ao processar data {col}: {e}")
|
| 453 |
+
|
| 454 |
+
async def process_keep_numeric_batch(df: pd.DataFrame, numeric_columns: List[tuple]):
|
| 455 |
+
"""Processa colunas numéricas que já estão no tipo correto"""
|
| 456 |
+
for col, rule in numeric_columns:
|
| 457 |
+
try:
|
| 458 |
+
if rule == "keep_as_int" and df[col].dtype != 'Int64':
|
| 459 |
+
df[col] = df[col].astype("Int64")
|
| 460 |
+
elif rule == "keep_as_float" and df[col].dtype != 'float64':
|
| 461 |
+
df[col] = df[col].astype("float64")
|
| 462 |
+
except Exception as e:
|
| 463 |
+
logging.warning(f"Erro ao manter tipo {col}: {e}")
|
| 464 |
+
|
| 465 |
+
async def process_convert_numeric_batch(df: pd.DataFrame, convert_columns: List[tuple]):
|
| 466 |
+
"""Processa conversões numéricas em lote com máxima otimização"""
|
| 467 |
+
for col, rule in convert_columns:
|
| 468 |
+
try:
|
| 469 |
+
if rule == "convert_text_to_int_safe":
|
| 470 |
+
df[col] = convert_to_int_ultra_optimized(df[col])
|
| 471 |
+
elif rule == "convert_text_to_float_safe":
|
| 472 |
+
df[col] = convert_to_float_ultra_optimized(df[col])
|
| 473 |
+
except Exception as e:
|
| 474 |
+
logging.warning(f"Erro ao converter {col}: {e}")
|
| 475 |
+
if rule == "parse_dates":
|
| 476 |
+
processed_df[col] = pd.to_datetime(
|
| 477 |
+
processed_df[col],
|
| 478 |
+
dayfirst=True,
|
| 479 |
+
errors='coerce'
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
elif rule == "parse_dates_advanced":
|
| 483 |
+
# Processamento avançado de datas com múltiplos formatos
|
| 484 |
+
processed_df[col] = await process_dates_advanced(processed_df[col])
|
| 485 |
+
|
| 486 |
+
elif rule == "keep_as_int":
|
| 487 |
+
# Já é inteiro, apenas garante tipo correto
|
| 488 |
+
if processed_df[col].dtype != 'Int64':
|
| 489 |
+
processed_df[col] = processed_df[col].astype("Int64")
|
| 490 |
+
|
| 491 |
+
elif rule == "keep_as_float":
|
| 492 |
+
# Já é float, apenas garante tipo correto
|
| 493 |
+
if processed_df[col].dtype != 'float64':
|
| 494 |
+
processed_df[col] = processed_df[col].astype("float64")
|
| 495 |
+
|
| 496 |
+
elif rule == "convert_text_to_int_safe":
|
| 497 |
+
# Conversão otimizada e segura para inteiros
|
| 498 |
+
processed_df[col] = convert_to_int_optimized(processed_df[col])
|
| 499 |
+
|
| 500 |
+
elif rule == "convert_text_to_float_safe":
|
| 501 |
+
# Conversão otimizada e segura para floats
|
| 502 |
+
processed_df[col] = convert_to_float_optimized(processed_df[col])
|
| 503 |
+
|
| 504 |
+
elif rule == "keep_as_text":
|
| 505 |
+
# Mantém como texto, apenas garante que é string
|
| 506 |
+
processed_df[col] = processed_df[col].astype(str)
|
| 507 |
+
|
| 508 |
+
except Exception as e:
|
| 509 |
+
logging.warning(f"Erro ao processar coluna {col} com regra {rule}: {e}")
|
| 510 |
+
# Em caso de erro, mantém coluna original
|
| 511 |
+
continue
|
| 512 |
+
|
| 513 |
+
col_time = time.time() - col_start_time
|
| 514 |
+
logging.debug(f"[OPTIMIZATION] Coluna {col} processada em {col_time:.2f}s")
|
| 515 |
+
|
| 516 |
+
total_time = time.time() - start_time
|
| 517 |
+
logging.info(f"[OPTIMIZATION] Processamento concluído em {total_time:.2f}s")
|
| 518 |
+
|
| 519 |
+
return processed_df
|
| 520 |
+
|
| 521 |
+
def convert_to_int_optimized(series: pd.Series) -> pd.Series:
|
| 522 |
+
"""
|
| 523 |
+
Conversão otimizada para inteiros
|
| 524 |
+
|
| 525 |
+
Args:
|
| 526 |
+
series: Série para converter
|
| 527 |
+
|
| 528 |
+
Returns:
|
| 529 |
+
Série convertida para Int64
|
| 530 |
+
"""
|
| 531 |
+
try:
|
| 532 |
+
# Operações vetorizadas para performance
|
| 533 |
+
cleaned = series.astype(str).str.strip()
|
| 534 |
+
|
| 535 |
+
# Remove valores inválidos
|
| 536 |
+
cleaned = cleaned.replace(['', 'nan', 'null', 'none', '-', 'NaN', 'NULL'], np.nan)
|
| 537 |
+
|
| 538 |
+
# Substitui vírgulas por pontos
|
| 539 |
+
cleaned = cleaned.str.replace(',', '.', regex=False)
|
| 540 |
+
|
| 541 |
+
# Converte para numérico
|
| 542 |
+
numeric = pd.to_numeric(cleaned, errors='coerce')
|
| 543 |
+
|
| 544 |
+
# Verifica se pode ser convertido para inteiro sem perda
|
| 545 |
+
# Só converte se todos os valores válidos são inteiros
|
| 546 |
+
valid_mask = numeric.notna()
|
| 547 |
+
if valid_mask.any():
|
| 548 |
+
valid_numbers = numeric[valid_mask]
|
| 549 |
+
# Verifica se são inteiros (sem parte decimal significativa)
|
| 550 |
+
is_integer_mask = np.abs(valid_numbers - np.round(valid_numbers)) < 1e-10
|
| 551 |
+
|
| 552 |
+
if is_integer_mask.all():
|
| 553 |
+
# Todos são inteiros, pode converter
|
| 554 |
+
result = numeric.round().astype("Int64")
|
| 555 |
+
else:
|
| 556 |
+
# Tem decimais, mantém como float mas avisa
|
| 557 |
+
logging.warning(f"Coluna contém decimais, mantendo como float")
|
| 558 |
+
result = numeric.astype("Float64")
|
| 559 |
+
else:
|
| 560 |
+
# Nenhum valor válido
|
| 561 |
+
result = pd.Series([pd.NA] * len(series), dtype="Int64")
|
| 562 |
+
|
| 563 |
+
return result
|
| 564 |
+
|
| 565 |
+
except Exception as e:
|
| 566 |
+
logging.error(f"Erro na conversão otimizada para int: {e}")
|
| 567 |
+
return series
|
| 568 |
+
|
| 569 |
+
def convert_to_float_optimized(series: pd.Series) -> pd.Series:
|
| 570 |
+
"""
|
| 571 |
+
Conversão otimizada para floats
|
| 572 |
+
|
| 573 |
+
Args:
|
| 574 |
+
series: Série para converter
|
| 575 |
+
|
| 576 |
+
Returns:
|
| 577 |
+
Série convertida para float64
|
| 578 |
+
"""
|
| 579 |
+
try:
|
| 580 |
+
# Operações vetorizadas para performance
|
| 581 |
+
cleaned = series.astype(str).str.strip()
|
| 582 |
+
|
| 583 |
+
# Remove valores inválidos
|
| 584 |
+
cleaned = cleaned.replace(['', 'nan', 'null', 'none', '-', 'NaN', 'NULL'], np.nan)
|
| 585 |
+
|
| 586 |
+
# Substitui vírgulas por pontos (formato brasileiro)
|
| 587 |
+
cleaned = cleaned.str.replace(',', '.', regex=False)
|
| 588 |
+
|
| 589 |
+
# Converte para numérico
|
| 590 |
+
result = pd.to_numeric(cleaned, errors='coerce')
|
| 591 |
+
|
| 592 |
+
return result
|
| 593 |
+
|
| 594 |
+
except Exception as e:
|
| 595 |
+
logging.error(f"Erro na conversão otimizada para float: {e}")
|
| 596 |
+
return series
|
| 597 |
+
|
| 598 |
+
def convert_to_int_ultra_optimized(series: pd.Series) -> pd.Series:
|
| 599 |
+
"""
|
| 600 |
+
Conversão ULTRA-OTIMIZADA para inteiros usando NumPy puro
|
| 601 |
+
"""
|
| 602 |
+
try:
|
| 603 |
+
# OTIMIZAÇÃO EXTREMA: Usa NumPy diretamente
|
| 604 |
+
values = series.values
|
| 605 |
+
|
| 606 |
+
# Se já é numérico, converte diretamente
|
| 607 |
+
if pd.api.types.is_numeric_dtype(series):
|
| 608 |
+
return pd.Series(values, dtype="Int64")
|
| 609 |
+
|
| 610 |
+
# Para strings, usa operações vetorizadas do NumPy
|
| 611 |
+
str_values = np.asarray(series.astype(str))
|
| 612 |
+
|
| 613 |
+
# Máscara para valores válidos
|
| 614 |
+
valid_mask = ~np.isin(str_values, ['', 'nan', 'null', 'none', '-', 'NaN', 'NULL'])
|
| 615 |
+
|
| 616 |
+
# Inicializa resultado
|
| 617 |
+
result = np.full(len(series), pd.NA, dtype=object)
|
| 618 |
+
|
| 619 |
+
if valid_mask.any():
|
| 620 |
+
valid_values = str_values[valid_mask]
|
| 621 |
+
|
| 622 |
+
# Remove vírgulas e converte
|
| 623 |
+
cleaned = np.char.replace(valid_values, ',', '.')
|
| 624 |
+
|
| 625 |
+
# Conversão vetorizada
|
| 626 |
+
try:
|
| 627 |
+
numeric_values = pd.to_numeric(cleaned, errors='coerce')
|
| 628 |
+
# Só converte se são realmente inteiros
|
| 629 |
+
int_mask = np.abs(numeric_values - np.round(numeric_values)) < 1e-10
|
| 630 |
+
int_values = np.round(numeric_values[int_mask]).astype('Int64')
|
| 631 |
+
|
| 632 |
+
# Atribui valores convertidos
|
| 633 |
+
valid_indices = np.where(valid_mask)[0]
|
| 634 |
+
int_indices = valid_indices[int_mask]
|
| 635 |
+
result[int_indices] = int_values
|
| 636 |
+
|
| 637 |
+
except Exception:
|
| 638 |
+
pass
|
| 639 |
+
|
| 640 |
+
return pd.Series(result, dtype="Int64")
|
| 641 |
+
|
| 642 |
+
except Exception as e:
|
| 643 |
+
logging.error(f"Erro na conversão ultra-otimizada para int: {e}")
|
| 644 |
+
return series
|
| 645 |
+
|
| 646 |
+
def convert_to_float_ultra_optimized(series: pd.Series) -> pd.Series:
|
| 647 |
+
"""
|
| 648 |
+
Conversão ULTRA-OTIMIZADA para floats usando NumPy puro
|
| 649 |
+
"""
|
| 650 |
+
try:
|
| 651 |
+
# OTIMIZAÇÃO EXTREMA: Usa NumPy diretamente
|
| 652 |
+
values = series.values
|
| 653 |
+
|
| 654 |
+
# Se já é numérico, retorna diretamente
|
| 655 |
+
if pd.api.types.is_numeric_dtype(series):
|
| 656 |
+
return series.astype('float64')
|
| 657 |
+
|
| 658 |
+
# Para strings, usa operações vetorizadas do NumPy
|
| 659 |
+
str_values = np.asarray(series.astype(str))
|
| 660 |
+
|
| 661 |
+
# Máscara para valores válidos
|
| 662 |
+
valid_mask = ~np.isin(str_values, ['', 'nan', 'null', 'none', '-', 'NaN', 'NULL'])
|
| 663 |
+
|
| 664 |
+
# Inicializa resultado
|
| 665 |
+
result = np.full(len(series), np.nan, dtype='float64')
|
| 666 |
+
|
| 667 |
+
if valid_mask.any():
|
| 668 |
+
valid_values = str_values[valid_mask]
|
| 669 |
+
|
| 670 |
+
# Remove vírgulas (formato brasileiro)
|
| 671 |
+
cleaned = np.char.replace(valid_values, ',', '.')
|
| 672 |
+
|
| 673 |
+
# Conversão vetorizada ultra-rápida
|
| 674 |
+
numeric_values = pd.to_numeric(cleaned, errors='coerce')
|
| 675 |
+
result[valid_mask] = numeric_values
|
| 676 |
+
|
| 677 |
+
return pd.Series(result, dtype='float64')
|
| 678 |
+
|
| 679 |
+
except Exception as e:
|
| 680 |
+
logging.error(f"Erro na conversão ultra-otimizada para float: {e}")
|
| 681 |
+
return series
|
| 682 |
+
|
| 683 |
+
def process_dates_vectorized(series: pd.Series) -> pd.Series:
|
| 684 |
+
"""
|
| 685 |
+
Processamento vetorizado ULTRA-OTIMIZADO de datas
|
| 686 |
+
"""
|
| 687 |
+
try:
|
| 688 |
+
# OTIMIZAÇÃO: Tenta conversão direta primeiro
|
| 689 |
+
try:
|
| 690 |
+
return pd.to_datetime(series, dayfirst=True, errors='coerce')
|
| 691 |
+
except:
|
| 692 |
+
pass
|
| 693 |
+
|
| 694 |
+
# Se falhou, usa abordagem mais robusta mas ainda otimizada
|
| 695 |
+
str_values = series.astype(str)
|
| 696 |
+
|
| 697 |
+
# Detecta formato mais comum na amostra
|
| 698 |
+
sample = str_values.dropna().head(100)
|
| 699 |
+
if len(sample) > 0:
|
| 700 |
+
first_val = sample.iloc[0]
|
| 701 |
+
|
| 702 |
+
# Detecta formato baseado no primeiro valor
|
| 703 |
+
if len(first_val) >= 10 and first_val[4] in ['-', '/']:
|
| 704 |
+
# Formato ISO
|
| 705 |
+
return pd.to_datetime(series, errors='coerce')
|
| 706 |
+
else:
|
| 707 |
+
# Formato brasileiro
|
| 708 |
+
return pd.to_datetime(series, dayfirst=True, errors='coerce')
|
| 709 |
+
|
| 710 |
+
return pd.to_datetime(series, errors='coerce')
|
| 711 |
+
|
| 712 |
+
except Exception as e:
|
| 713 |
+
logging.error(f"Erro no processamento vetorizado de datas: {e}")
|
| 714 |
+
return series
|
| 715 |
+
|
| 716 |
+
async def csv_processing_node(state: CSVProcessingState) -> CSVProcessingState:
|
| 717 |
+
"""
|
| 718 |
+
Nó principal para processamento de CSV
|
| 719 |
+
|
| 720 |
+
Args:
|
| 721 |
+
state: Estado do processamento CSV
|
| 722 |
+
|
| 723 |
+
Returns:
|
| 724 |
+
Estado atualizado
|
| 725 |
+
"""
|
| 726 |
+
try:
|
| 727 |
+
file_path = state["file_path"]
|
| 728 |
+
|
| 729 |
+
# Copia arquivo para diretório de upload
|
| 730 |
+
shutil.copy(file_path, UPLOADED_CSV_PATH)
|
| 731 |
+
logging.info(f"[CSV_PROCESSING] Arquivo copiado para: {UPLOADED_CSV_PATH}")
|
| 732 |
+
|
| 733 |
+
# OTIMIZAÇÃO EXTREMA: Leitura de CSV ultra-otimizada
|
| 734 |
+
separators = [';', ',', '\t', '|']
|
| 735 |
+
df = None
|
| 736 |
+
used_separator = None
|
| 737 |
+
|
| 738 |
+
# Detecta separador com amostra mínima
|
| 739 |
+
for sep in separators:
|
| 740 |
+
try:
|
| 741 |
+
test_df = pd.read_csv(file_path, sep=sep, nrows=3, engine='c') # Engine C é mais rápido
|
| 742 |
+
if len(test_df.columns) > 1:
|
| 743 |
+
# OTIMIZAÇÃO: Lê com configurações de performance máxima
|
| 744 |
+
df = pd.read_csv(
|
| 745 |
+
file_path,
|
| 746 |
+
sep=sep,
|
| 747 |
+
encoding='utf-8',
|
| 748 |
+
on_bad_lines="skip",
|
| 749 |
+
engine='c', # Engine C para máxima performance
|
| 750 |
+
low_memory=False, # Evita warnings de tipos mistos
|
| 751 |
+
dtype=str # Lê tudo como string primeiro (mais rápido)
|
| 752 |
+
)
|
| 753 |
+
used_separator = sep
|
| 754 |
+
break
|
| 755 |
+
except:
|
| 756 |
+
continue
|
| 757 |
+
|
| 758 |
+
if df is None:
|
| 759 |
+
raise ValueError("Não foi possível detectar o formato do CSV")
|
| 760 |
+
|
| 761 |
+
logging.info(f"[CSV_PROCESSING] CSV lido com separador '{used_separator}', {len(df)} linhas, {len(df.columns)} colunas")
|
| 762 |
+
|
| 763 |
+
# Detecta tipos de colunas automaticamente
|
| 764 |
+
column_info = await detect_column_types(df)
|
| 765 |
+
|
| 766 |
+
# Processa DataFrame
|
| 767 |
+
processed_df = await process_dataframe_generic(df, column_info)
|
| 768 |
+
|
| 769 |
+
# Estatísticas do processamento
|
| 770 |
+
processing_stats = {
|
| 771 |
+
"original_rows": len(df),
|
| 772 |
+
"processed_rows": len(processed_df),
|
| 773 |
+
"original_columns": len(df.columns),
|
| 774 |
+
"processed_columns": len(processed_df.columns),
|
| 775 |
+
"separator_used": used_separator,
|
| 776 |
+
"date_columns_detected": len(column_info["date_columns"]),
|
| 777 |
+
"numeric_columns_detected": len(column_info["numeric_columns"]),
|
| 778 |
+
"text_columns_detected": len(column_info["text_columns"])
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
# Amostra dos dados para o estado
|
| 782 |
+
csv_data_sample = {
|
| 783 |
+
"head": processed_df.head(5).to_dict(),
|
| 784 |
+
"dtypes": processed_df.dtypes.astype(str).to_dict(),
|
| 785 |
+
"columns": list(processed_df.columns)
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
# Armazena DataFrame processado no gerenciador de objetos
|
| 789 |
+
obj_manager = get_object_manager()
|
| 790 |
+
df_id = obj_manager.store_object(processed_df, "processed_dataframe")
|
| 791 |
+
|
| 792 |
+
# Atualiza estado
|
| 793 |
+
state.update({
|
| 794 |
+
"success": True,
|
| 795 |
+
"message": f"✅ CSV processado com sucesso! {processing_stats['processed_rows']} linhas, {processing_stats['processed_columns']} colunas",
|
| 796 |
+
"csv_data_sample": csv_data_sample,
|
| 797 |
+
"column_info": column_info,
|
| 798 |
+
"processing_stats": processing_stats,
|
| 799 |
+
"dataframe_id": df_id
|
| 800 |
+
})
|
| 801 |
+
|
| 802 |
+
logging.info(f"[CSV_PROCESSING] Processamento concluído: {processing_stats}")
|
| 803 |
+
|
| 804 |
+
except Exception as e:
|
| 805 |
+
error_msg = f"❌ Erro ao processar CSV: {e}"
|
| 806 |
+
logging.error(f"[CSV_PROCESSING] {error_msg}")
|
| 807 |
+
state.update({
|
| 808 |
+
"success": False,
|
| 809 |
+
"message": error_msg,
|
| 810 |
+
"csv_data_sample": {},
|
| 811 |
+
"column_info": {},
|
| 812 |
+
"processing_stats": {}
|
| 813 |
+
})
|
| 814 |
+
|
| 815 |
+
return state
|
nodes/custom_nodes.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nós personalizados para funcionalidades específicas
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, Any, TypedDict
|
| 8 |
+
|
| 9 |
+
from utils.database import create_sql_database
|
| 10 |
+
from utils.config import UPLOADED_CSV_PATH, SQL_DB_PATH, DEFAULT_CSV_PATH
|
| 11 |
+
from agents.sql_agent import SQLAgentManager
|
| 12 |
+
from nodes.csv_processing_node import csv_processing_node
|
| 13 |
+
from nodes.database_node import create_database_from_dataframe_node, load_existing_database_node
|
| 14 |
+
|
| 15 |
+
class FileUploadState(TypedDict):
|
| 16 |
+
"""Estado para upload de arquivos"""
|
| 17 |
+
file_path: str
|
| 18 |
+
success: bool
|
| 19 |
+
message: str
|
| 20 |
+
engine: Any
|
| 21 |
+
sql_agent: SQLAgentManager
|
| 22 |
+
cache_manager: Any
|
| 23 |
+
|
| 24 |
+
class ResetState(TypedDict):
|
| 25 |
+
"""Estado para reset do sistema"""
|
| 26 |
+
success: bool
|
| 27 |
+
message: str
|
| 28 |
+
engine: Any
|
| 29 |
+
sql_agent: SQLAgentManager
|
| 30 |
+
cache_manager: Any
|
| 31 |
+
|
| 32 |
+
async def handle_csv_upload_node(state: FileUploadState) -> FileUploadState:
|
| 33 |
+
"""
|
| 34 |
+
Nó para processar upload de CSV
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
state: Estado do upload
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
Estado atualizado
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
file_path = state["file_path"]
|
| 44 |
+
|
| 45 |
+
# Etapa 1: Processa CSV usando nova arquitetura
|
| 46 |
+
csv_state = {
|
| 47 |
+
"file_path": file_path,
|
| 48 |
+
"success": False,
|
| 49 |
+
"message": "",
|
| 50 |
+
"csv_data_sample": {},
|
| 51 |
+
"column_info": {},
|
| 52 |
+
"processing_stats": {}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
csv_result = await csv_processing_node(csv_state)
|
| 56 |
+
if not csv_result["success"]:
|
| 57 |
+
raise Exception(csv_result["message"])
|
| 58 |
+
|
| 59 |
+
# Etapa 2: Cria banco de dados
|
| 60 |
+
db_result = await create_database_from_dataframe_node(csv_result)
|
| 61 |
+
if not db_result["success"]:
|
| 62 |
+
raise Exception(db_result["message"])
|
| 63 |
+
|
| 64 |
+
# Recupera objetos criados
|
| 65 |
+
from utils.object_manager import get_object_manager
|
| 66 |
+
obj_manager = get_object_manager()
|
| 67 |
+
|
| 68 |
+
engine = obj_manager.get_engine(db_result["engine_id"])
|
| 69 |
+
db = obj_manager.get_object(db_result["db_id"])
|
| 70 |
+
|
| 71 |
+
logging.info("[UPLOAD] Novo banco carregado e DB atualizado usando nova arquitetura.")
|
| 72 |
+
|
| 73 |
+
# Recria agente SQL
|
| 74 |
+
sql_agent = SQLAgentManager(db)
|
| 75 |
+
|
| 76 |
+
# Limpa cache
|
| 77 |
+
state["cache_manager"].clear_cache()
|
| 78 |
+
|
| 79 |
+
# Atualiza estado
|
| 80 |
+
state["engine"] = engine
|
| 81 |
+
state["sql_agent"] = sql_agent
|
| 82 |
+
state["success"] = True
|
| 83 |
+
state["message"] = "✅ CSV carregado com sucesso!"
|
| 84 |
+
|
| 85 |
+
logging.info("[UPLOAD] Novo banco carregado e agente recriado. Cache limpo.")
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
error_msg = f"❌ Erro ao processar CSV: {e}"
|
| 89 |
+
logging.error(f"[ERRO] Falha ao processar novo CSV: {e}")
|
| 90 |
+
state["success"] = False
|
| 91 |
+
state["message"] = error_msg
|
| 92 |
+
|
| 93 |
+
return state
|
| 94 |
+
|
| 95 |
+
async def reset_system_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 96 |
+
"""
|
| 97 |
+
Nó para resetar o sistema ao estado inicial
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
state: Estado do reset
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Estado atualizado
|
| 104 |
+
"""
|
| 105 |
+
try:
|
| 106 |
+
from utils.object_manager import get_object_manager
|
| 107 |
+
from agents.sql_agent import SQLAgentManager
|
| 108 |
+
|
| 109 |
+
obj_manager = get_object_manager()
|
| 110 |
+
|
| 111 |
+
# Remove CSV personalizado se existir
|
| 112 |
+
if os.path.exists(UPLOADED_CSV_PATH):
|
| 113 |
+
os.remove(UPLOADED_CSV_PATH)
|
| 114 |
+
logging.info("[RESET] CSV personalizado removido.")
|
| 115 |
+
|
| 116 |
+
# Recria banco com CSV padrão usando nova arquitetura
|
| 117 |
+
csv_state = {
|
| 118 |
+
"file_path": DEFAULT_CSV_PATH,
|
| 119 |
+
"success": False,
|
| 120 |
+
"message": "",
|
| 121 |
+
"csv_data_sample": {},
|
| 122 |
+
"column_info": {},
|
| 123 |
+
"processing_stats": {}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
csv_result = await csv_processing_node(csv_state)
|
| 127 |
+
if not csv_result["success"]:
|
| 128 |
+
raise Exception(csv_result["message"])
|
| 129 |
+
|
| 130 |
+
# Cria banco de dados
|
| 131 |
+
db_result = await create_database_from_dataframe_node(csv_result)
|
| 132 |
+
if not db_result["success"]:
|
| 133 |
+
raise Exception(db_result["message"])
|
| 134 |
+
|
| 135 |
+
# Recupera objetos criados
|
| 136 |
+
engine = obj_manager.get_engine(db_result["engine_id"])
|
| 137 |
+
db = obj_manager.get_object(db_result["db_id"])
|
| 138 |
+
|
| 139 |
+
# Recria agente SQL
|
| 140 |
+
sql_agent = SQLAgentManager(db)
|
| 141 |
+
|
| 142 |
+
# Atualiza objetos no gerenciador
|
| 143 |
+
engine_id = obj_manager.store_engine(engine)
|
| 144 |
+
agent_id = obj_manager.store_sql_agent(sql_agent)
|
| 145 |
+
|
| 146 |
+
# Limpa cache se disponível
|
| 147 |
+
cache_id = state.get("cache_id")
|
| 148 |
+
if cache_id:
|
| 149 |
+
cache_manager = obj_manager.get_cache_manager(cache_id)
|
| 150 |
+
if cache_manager:
|
| 151 |
+
cache_manager.clear_cache()
|
| 152 |
+
|
| 153 |
+
# Atualiza estado
|
| 154 |
+
state.update({
|
| 155 |
+
"engine_id": engine_id,
|
| 156 |
+
"agent_id": agent_id,
|
| 157 |
+
"success": True,
|
| 158 |
+
"message": "🔄 Sistema resetado para o estado inicial."
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
logging.info("[RESET] Sistema resetado com sucesso.")
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
error_msg = f"❌ Erro ao resetar: {e}"
|
| 165 |
+
logging.error(f"[ERRO] Falha ao resetar sistema: {e}")
|
| 166 |
+
state.update({
|
| 167 |
+
"success": False,
|
| 168 |
+
"message": error_msg
|
| 169 |
+
})
|
| 170 |
+
|
| 171 |
+
return state
|
| 172 |
+
|
| 173 |
+
async def validate_system_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 174 |
+
"""
|
| 175 |
+
Nó para validar o estado do sistema
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
state: Estado atual do sistema
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
Estado com informações de validação
|
| 182 |
+
"""
|
| 183 |
+
validation_results = {
|
| 184 |
+
"database_valid": False,
|
| 185 |
+
"agent_valid": False,
|
| 186 |
+
"cache_valid": False,
|
| 187 |
+
"overall_valid": False
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
# Valida banco de dados
|
| 192 |
+
if state.get("engine"):
|
| 193 |
+
from utils.database import validate_database
|
| 194 |
+
validation_results["database_valid"] = validate_database(state["engine"])
|
| 195 |
+
|
| 196 |
+
# Valida agente SQL
|
| 197 |
+
if state.get("sql_agent"):
|
| 198 |
+
validation_results["agent_valid"] = state["sql_agent"].validate_agent()
|
| 199 |
+
|
| 200 |
+
# Valida cache
|
| 201 |
+
if state.get("cache_manager"):
|
| 202 |
+
validation_results["cache_valid"] = True # Cache sempre válido se existe
|
| 203 |
+
|
| 204 |
+
# Validação geral
|
| 205 |
+
validation_results["overall_valid"] = all([
|
| 206 |
+
validation_results["database_valid"],
|
| 207 |
+
validation_results["agent_valid"],
|
| 208 |
+
validation_results["cache_valid"]
|
| 209 |
+
])
|
| 210 |
+
|
| 211 |
+
state["validation"] = validation_results
|
| 212 |
+
logging.info(f"[VALIDATION] Sistema válido: {validation_results['overall_valid']}")
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
logging.error(f"[VALIDATION] Erro na validação: {e}")
|
| 216 |
+
state["validation"] = validation_results
|
| 217 |
+
|
| 218 |
+
return state
|
| 219 |
+
|
| 220 |
+
async def get_system_info_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 221 |
+
"""
|
| 222 |
+
Nó para obter informações do sistema
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
state: Estado atual do sistema
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
Estado com informações do sistema
|
| 229 |
+
"""
|
| 230 |
+
system_info = {
|
| 231 |
+
"csv_active": None,
|
| 232 |
+
"database_path": SQL_DB_PATH,
|
| 233 |
+
"agent_info": None,
|
| 234 |
+
"cache_stats": None
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
try:
|
| 238 |
+
# Informações do CSV ativo
|
| 239 |
+
from utils.config import get_active_csv_path
|
| 240 |
+
system_info["csv_active"] = get_active_csv_path()
|
| 241 |
+
|
| 242 |
+
# Informações do agente
|
| 243 |
+
if state.get("sql_agent"):
|
| 244 |
+
system_info["agent_info"] = state["sql_agent"].get_agent_info()
|
| 245 |
+
|
| 246 |
+
# Estatísticas do cache
|
| 247 |
+
if state.get("cache_manager"):
|
| 248 |
+
cache_manager = state["cache_manager"]
|
| 249 |
+
system_info["cache_stats"] = {
|
| 250 |
+
"cached_queries": len(cache_manager.query_cache),
|
| 251 |
+
"history_entries": len(cache_manager.history_log),
|
| 252 |
+
"recent_history_size": len(cache_manager.recent_history)
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
state["system_info"] = system_info
|
| 256 |
+
logging.info("[SYSTEM_INFO] Informações do sistema coletadas")
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logging.error(f"[SYSTEM_INFO] Erro ao coletar informações: {e}")
|
| 260 |
+
state["system_info"] = system_info
|
| 261 |
+
|
| 262 |
+
return state
|
| 263 |
+
|
| 264 |
+
class CustomNodeManager:
|
| 265 |
+
"""
|
| 266 |
+
Gerenciador dos nós personalizados
|
| 267 |
+
"""
|
| 268 |
+
|
| 269 |
+
def __init__(self):
|
| 270 |
+
self.node_functions = {
|
| 271 |
+
"csv_upload": handle_csv_upload_node,
|
| 272 |
+
"system_reset": reset_system_node,
|
| 273 |
+
"system_validation": validate_system_node,
|
| 274 |
+
"system_info": get_system_info_node
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
def get_node_function(self, node_name: str):
|
| 278 |
+
"""Retorna função do nó pelo nome"""
|
| 279 |
+
return self.node_functions.get(node_name)
|
| 280 |
+
|
| 281 |
+
async def execute_node(self, node_name: str, state: Dict[str, Any]) -> Dict[str, Any]:
|
| 282 |
+
"""
|
| 283 |
+
Executa um nó específico
|
| 284 |
+
|
| 285 |
+
Args:
|
| 286 |
+
node_name: Nome do nó
|
| 287 |
+
state: Estado atual
|
| 288 |
+
|
| 289 |
+
Returns:
|
| 290 |
+
Estado atualizado
|
| 291 |
+
"""
|
| 292 |
+
node_function = self.get_node_function(node_name)
|
| 293 |
+
if node_function:
|
| 294 |
+
return await node_function(state)
|
| 295 |
+
else:
|
| 296 |
+
logging.error(f"Nó não encontrado: {node_name}")
|
| 297 |
+
return state
|
nodes/database_node.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para operações de banco de dados
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import logging
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from typing import Dict, Any, TypedDict, Optional
|
| 8 |
+
from sqlalchemy import create_engine
|
| 9 |
+
|
| 10 |
+
from utils.config import SQL_DB_PATH
|
| 11 |
+
from utils.database import create_sql_database, validate_database
|
| 12 |
+
from utils.object_manager import get_object_manager
|
| 13 |
+
|
| 14 |
+
class DatabaseState(TypedDict):
|
| 15 |
+
"""Estado para operações de banco de dados"""
|
| 16 |
+
success: bool
|
| 17 |
+
message: str
|
| 18 |
+
database_info: dict
|
| 19 |
+
engine_id: str
|
| 20 |
+
db_id: str
|
| 21 |
+
|
| 22 |
+
async def create_database_from_dataframe_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 23 |
+
"""
|
| 24 |
+
Nó para criar banco de dados a partir de DataFrame processado
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
state: Estado contendo informações do DataFrame processado
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Estado atualizado com informações do banco
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
obj_manager = get_object_manager()
|
| 34 |
+
|
| 35 |
+
# Recupera DataFrame processado
|
| 36 |
+
df_id = state.get("dataframe_id")
|
| 37 |
+
if not df_id:
|
| 38 |
+
raise ValueError("ID do DataFrame não encontrado no estado")
|
| 39 |
+
|
| 40 |
+
processed_df = obj_manager.get_object(df_id)
|
| 41 |
+
if processed_df is None:
|
| 42 |
+
raise ValueError("DataFrame processado não encontrado")
|
| 43 |
+
|
| 44 |
+
# Recupera informações das colunas
|
| 45 |
+
column_info = state.get("column_info", {})
|
| 46 |
+
sql_types = column_info.get("sql_types", {})
|
| 47 |
+
|
| 48 |
+
# Cria engine do banco
|
| 49 |
+
engine = create_engine(f"sqlite:///{SQL_DB_PATH}")
|
| 50 |
+
|
| 51 |
+
# Salva DataFrame no banco
|
| 52 |
+
processed_df.to_sql(
|
| 53 |
+
"tabela",
|
| 54 |
+
engine,
|
| 55 |
+
index=False,
|
| 56 |
+
if_exists="replace",
|
| 57 |
+
dtype=sql_types
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
logging.info(f"[DATABASE] Banco criado com {len(processed_df)} registros")
|
| 61 |
+
|
| 62 |
+
# Cria objeto SQLDatabase do LangChain
|
| 63 |
+
db = create_sql_database(engine)
|
| 64 |
+
|
| 65 |
+
# Valida banco
|
| 66 |
+
is_valid = validate_database(engine)
|
| 67 |
+
|
| 68 |
+
# Armazena objetos no gerenciador
|
| 69 |
+
engine_id = obj_manager.store_engine(engine)
|
| 70 |
+
db_id = obj_manager.store_database(db)
|
| 71 |
+
|
| 72 |
+
# Informações do banco
|
| 73 |
+
database_info = {
|
| 74 |
+
"path": SQL_DB_PATH,
|
| 75 |
+
"table_name": "tabela",
|
| 76 |
+
"total_records": len(processed_df),
|
| 77 |
+
"columns": list(processed_df.columns),
|
| 78 |
+
"column_types": {col: str(dtype) for col, dtype in processed_df.dtypes.items()},
|
| 79 |
+
"is_valid": is_valid,
|
| 80 |
+
"sql_types_used": {col: str(sql_type) for col, sql_type in sql_types.items()}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Atualiza estado
|
| 84 |
+
state.update({
|
| 85 |
+
"success": True,
|
| 86 |
+
"message": f"✅ Banco de dados criado com sucesso! {len(processed_df)} registros salvos",
|
| 87 |
+
"database_info": database_info,
|
| 88 |
+
"engine_id": engine_id,
|
| 89 |
+
"db_id": db_id
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
logging.info(f"[DATABASE] Banco criado e validado: {database_info}")
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
error_msg = f"❌ Erro ao criar banco de dados: {e}"
|
| 96 |
+
logging.error(f"[DATABASE] {error_msg}")
|
| 97 |
+
state.update({
|
| 98 |
+
"success": False,
|
| 99 |
+
"message": error_msg,
|
| 100 |
+
"database_info": {},
|
| 101 |
+
"engine_id": "",
|
| 102 |
+
"db_id": ""
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
return state
|
| 106 |
+
|
| 107 |
+
async def load_existing_database_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 108 |
+
"""
|
| 109 |
+
Nó para carregar banco de dados existente
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
state: Estado atual
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Estado atualizado com informações do banco existente
|
| 116 |
+
"""
|
| 117 |
+
try:
|
| 118 |
+
if not os.path.exists(SQL_DB_PATH):
|
| 119 |
+
raise ValueError("Banco de dados não encontrado")
|
| 120 |
+
|
| 121 |
+
# Cria engine
|
| 122 |
+
engine = create_engine(f"sqlite:///{SQL_DB_PATH}")
|
| 123 |
+
|
| 124 |
+
# Cria objeto SQLDatabase
|
| 125 |
+
db = create_sql_database(engine)
|
| 126 |
+
|
| 127 |
+
# Valida banco
|
| 128 |
+
is_valid = validate_database(engine)
|
| 129 |
+
|
| 130 |
+
# Obtém informações do banco
|
| 131 |
+
try:
|
| 132 |
+
sample_df = pd.read_sql_query("SELECT * FROM tabela LIMIT 5", engine)
|
| 133 |
+
total_records_df = pd.read_sql_query("SELECT COUNT(*) as count FROM tabela", engine)
|
| 134 |
+
total_records = total_records_df.iloc[0]['count']
|
| 135 |
+
|
| 136 |
+
database_info = {
|
| 137 |
+
"path": SQL_DB_PATH,
|
| 138 |
+
"table_name": "tabela",
|
| 139 |
+
"total_records": total_records,
|
| 140 |
+
"columns": list(sample_df.columns),
|
| 141 |
+
"column_types": {col: str(dtype) for col, dtype in sample_df.dtypes.items()},
|
| 142 |
+
"is_valid": is_valid,
|
| 143 |
+
"sample_data": sample_df.head(3).to_dict()
|
| 144 |
+
}
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logging.warning(f"Erro ao obter informações detalhadas do banco: {e}")
|
| 147 |
+
database_info = {
|
| 148 |
+
"path": SQL_DB_PATH,
|
| 149 |
+
"table_name": "tabela",
|
| 150 |
+
"is_valid": is_valid,
|
| 151 |
+
"error": str(e)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
# Armazena objetos no gerenciador
|
| 155 |
+
obj_manager = get_object_manager()
|
| 156 |
+
engine_id = obj_manager.store_engine(engine)
|
| 157 |
+
db_id = obj_manager.store_database(db)
|
| 158 |
+
|
| 159 |
+
# Atualiza estado
|
| 160 |
+
state.update({
|
| 161 |
+
"success": True,
|
| 162 |
+
"message": "✅ Banco de dados existente carregado com sucesso",
|
| 163 |
+
"database_info": database_info,
|
| 164 |
+
"engine_id": engine_id,
|
| 165 |
+
"db_id": db_id
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
logging.info(f"[DATABASE] Banco existente carregado: {database_info}")
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
error_msg = f"❌ Erro ao carregar banco existente: {e}"
|
| 172 |
+
logging.error(f"[DATABASE] {error_msg}")
|
| 173 |
+
state.update({
|
| 174 |
+
"success": False,
|
| 175 |
+
"message": error_msg,
|
| 176 |
+
"database_info": {},
|
| 177 |
+
"engine_id": "",
|
| 178 |
+
"db_id": ""
|
| 179 |
+
})
|
| 180 |
+
|
| 181 |
+
return state
|
| 182 |
+
|
| 183 |
+
async def get_database_sample_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 184 |
+
"""
|
| 185 |
+
Nó para obter amostra dos dados do banco
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
state: Estado contendo ID da engine
|
| 189 |
+
|
| 190 |
+
Returns:
|
| 191 |
+
Estado atualizado com amostra dos dados
|
| 192 |
+
"""
|
| 193 |
+
try:
|
| 194 |
+
obj_manager = get_object_manager()
|
| 195 |
+
|
| 196 |
+
# Recupera engine
|
| 197 |
+
engine_id = state.get("engine_id")
|
| 198 |
+
if not engine_id:
|
| 199 |
+
raise ValueError("ID da engine não encontrado")
|
| 200 |
+
|
| 201 |
+
engine = obj_manager.get_engine(engine_id)
|
| 202 |
+
if not engine:
|
| 203 |
+
raise ValueError("Engine não encontrada")
|
| 204 |
+
|
| 205 |
+
# Obtém amostra dos dados
|
| 206 |
+
sample_df = pd.read_sql_query("SELECT * FROM tabela LIMIT 10", engine)
|
| 207 |
+
|
| 208 |
+
# Converte para formato serializável
|
| 209 |
+
db_sample_dict = {
|
| 210 |
+
"data": sample_df.to_dict('records'),
|
| 211 |
+
"columns": list(sample_df.columns),
|
| 212 |
+
"dtypes": sample_df.dtypes.astype(str).to_dict(),
|
| 213 |
+
"shape": sample_df.shape
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
state["db_sample_dict"] = db_sample_dict
|
| 217 |
+
|
| 218 |
+
logging.info(f"[DATABASE] Amostra obtida: {sample_df.shape[0]} registros")
|
| 219 |
+
|
| 220 |
+
except Exception as e:
|
| 221 |
+
error_msg = f"Erro ao obter amostra do banco: {e}"
|
| 222 |
+
logging.error(f"[DATABASE] {error_msg}")
|
| 223 |
+
state["db_sample_dict"] = {}
|
| 224 |
+
state["error"] = error_msg
|
| 225 |
+
|
| 226 |
+
return state
|
nodes/graph_generation_node.py
ADDED
|
@@ -0,0 +1,1015 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para geração de gráficos
|
| 3 |
+
"""
|
| 4 |
+
import io
|
| 5 |
+
import logging
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import matplotlib.dates as mdates
|
| 10 |
+
from PIL import Image
|
| 11 |
+
from typing import Dict, Any, Optional
|
| 12 |
+
|
| 13 |
+
from utils.object_manager import get_object_manager
|
| 14 |
+
|
| 15 |
+
async def graph_generation_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 16 |
+
"""
|
| 17 |
+
Nó para geração de gráficos baseado no tipo selecionado
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
state: Estado atual do agente
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Estado atualizado com gráfico gerado
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
logging.info("[GRAPH_GENERATION] Iniciando geração de gráfico")
|
| 27 |
+
|
| 28 |
+
# Verifica se há tipo de gráfico selecionado
|
| 29 |
+
graph_type = state.get("graph_type")
|
| 30 |
+
if not graph_type:
|
| 31 |
+
logging.info("[GRAPH_GENERATION] Nenhum tipo de gráfico selecionado, pulando geração")
|
| 32 |
+
return state
|
| 33 |
+
|
| 34 |
+
# Verifica se há erro anterior
|
| 35 |
+
if state.get("graph_error"):
|
| 36 |
+
logging.info("[GRAPH_GENERATION] Erro anterior detectado, pulando geração")
|
| 37 |
+
return state
|
| 38 |
+
|
| 39 |
+
# Recupera dados do gráfico
|
| 40 |
+
graph_data = state.get("graph_data", {})
|
| 41 |
+
data_id = graph_data.get("data_id")
|
| 42 |
+
|
| 43 |
+
if not data_id:
|
| 44 |
+
error_msg = "ID dos dados do gráfico não encontrado"
|
| 45 |
+
logging.error(f"[GRAPH_GENERATION] {error_msg}")
|
| 46 |
+
state.update({
|
| 47 |
+
"graph_error": error_msg,
|
| 48 |
+
"graph_generated": False
|
| 49 |
+
})
|
| 50 |
+
return state
|
| 51 |
+
|
| 52 |
+
# Recupera DataFrame dos dados
|
| 53 |
+
obj_manager = get_object_manager()
|
| 54 |
+
df = obj_manager.get_object(data_id)
|
| 55 |
+
|
| 56 |
+
if df is None or df.empty:
|
| 57 |
+
error_msg = "Dados do gráfico não encontrados ou vazios"
|
| 58 |
+
logging.error(f"[GRAPH_GENERATION] {error_msg}")
|
| 59 |
+
state.update({
|
| 60 |
+
"graph_error": error_msg,
|
| 61 |
+
"graph_generated": False
|
| 62 |
+
})
|
| 63 |
+
return state
|
| 64 |
+
|
| 65 |
+
# Gera título do gráfico baseado na pergunta do usuário
|
| 66 |
+
user_query = state.get("user_input", "")
|
| 67 |
+
title = f"Visualização: {user_query[:50]}..." if len(user_query) > 50 else f"Visualização: {user_query}"
|
| 68 |
+
|
| 69 |
+
# Gera o gráfico
|
| 70 |
+
graph_image = await generate_graph(df, graph_type, title, user_query)
|
| 71 |
+
|
| 72 |
+
if graph_image is None:
|
| 73 |
+
error_msg = f"Falha ao gerar gráfico do tipo {graph_type}"
|
| 74 |
+
logging.error(f"[GRAPH_GENERATION] {error_msg}")
|
| 75 |
+
state.update({
|
| 76 |
+
"graph_error": error_msg,
|
| 77 |
+
"graph_generated": False
|
| 78 |
+
})
|
| 79 |
+
return state
|
| 80 |
+
|
| 81 |
+
# Armazena imagem do gráfico no ObjectManager
|
| 82 |
+
graph_image_id = obj_manager.store_object(graph_image, "graph_image")
|
| 83 |
+
|
| 84 |
+
# Atualiza estado
|
| 85 |
+
state.update({
|
| 86 |
+
"graph_image_id": graph_image_id,
|
| 87 |
+
"graph_generated": True,
|
| 88 |
+
"graph_error": None
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
logging.info(f"[GRAPH_GENERATION] Gráfico gerado com sucesso: {graph_type}")
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
error_msg = f"Erro na geração de gráfico: {e}"
|
| 95 |
+
logging.error(f"[GRAPH_GENERATION] {error_msg}")
|
| 96 |
+
state.update({
|
| 97 |
+
"graph_error": error_msg,
|
| 98 |
+
"graph_generated": False
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
return state
|
| 102 |
+
|
| 103 |
+
async def generate_graph(df: pd.DataFrame, graph_type: str, title: str = None, user_query: str = None) -> Optional[Image.Image]:
|
| 104 |
+
"""
|
| 105 |
+
Gera um gráfico com base no DataFrame e tipo especificado
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
df: DataFrame com os dados
|
| 109 |
+
graph_type: Tipo de gráfico a ser gerado
|
| 110 |
+
title: Título do gráfico
|
| 111 |
+
user_query: Pergunta original do usuário
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
Imagem PIL do gráfico ou None se falhar
|
| 115 |
+
"""
|
| 116 |
+
logging.info(f"[GRAPH_GENERATION] Gerando gráfico tipo {graph_type}. DataFrame: {len(df)} linhas")
|
| 117 |
+
|
| 118 |
+
if df.empty:
|
| 119 |
+
logging.warning("[GRAPH_GENERATION] DataFrame vazio")
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
# Preparar dados usando lógica UNIFICADA
|
| 124 |
+
prepared_df = prepare_data_for_graph_unified(df, graph_type, user_query)
|
| 125 |
+
if prepared_df.empty:
|
| 126 |
+
logging.warning("[GRAPH_GENERATION] DataFrame preparado está vazio")
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
# Configurações gerais
|
| 130 |
+
plt.style.use('default')
|
| 131 |
+
colors = plt.cm.tab10.colors
|
| 132 |
+
|
| 133 |
+
# Gerar gráfico baseado no tipo
|
| 134 |
+
if graph_type == 'line_simple':
|
| 135 |
+
return await generate_line_simple(prepared_df, title, colors)
|
| 136 |
+
elif graph_type == 'multiline':
|
| 137 |
+
return await generate_multiline(prepared_df, title, colors)
|
| 138 |
+
elif graph_type == 'area':
|
| 139 |
+
return await generate_area(prepared_df, title, colors)
|
| 140 |
+
elif graph_type == 'bar_vertical':
|
| 141 |
+
return await generate_bar_vertical(prepared_df, title, colors)
|
| 142 |
+
elif graph_type == 'bar_horizontal':
|
| 143 |
+
return await generate_bar_horizontal(prepared_df, title, colors)
|
| 144 |
+
elif graph_type == 'bar_grouped':
|
| 145 |
+
return await generate_bar_grouped(prepared_df, title, colors)
|
| 146 |
+
elif graph_type == 'bar_stacked':
|
| 147 |
+
return await generate_bar_stacked(prepared_df, title, colors)
|
| 148 |
+
elif graph_type == 'pie':
|
| 149 |
+
return await generate_pie(prepared_df, title, colors)
|
| 150 |
+
elif graph_type == 'donut':
|
| 151 |
+
return await generate_donut(prepared_df, title, colors)
|
| 152 |
+
elif graph_type == 'pie_multiple':
|
| 153 |
+
return await generate_pie_multiple(prepared_df, title, colors)
|
| 154 |
+
else:
|
| 155 |
+
logging.warning(f"[GRAPH_GENERATION] Tipo '{graph_type}' não reconhecido, usando bar_vertical")
|
| 156 |
+
return await generate_bar_vertical(prepared_df, title, colors)
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logging.error(f"[GRAPH_GENERATION] Erro ao gerar gráfico: {e}")
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
def analyze_dataframe_structure(df: pd.DataFrame) -> Dict[str, Any]:
|
| 163 |
+
"""
|
| 164 |
+
Analisa a estrutura do DataFrame e retorna informações detalhadas
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
df: DataFrame a ser analisado
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
Dicionário com informações sobre tipos de colunas e estrutura
|
| 171 |
+
"""
|
| 172 |
+
if df.empty:
|
| 173 |
+
return {
|
| 174 |
+
'numeric_cols': [],
|
| 175 |
+
'date_cols': [],
|
| 176 |
+
'categorical_cols': [],
|
| 177 |
+
'total_cols': 0,
|
| 178 |
+
'has_multiple_numerics': False,
|
| 179 |
+
'has_multiple_categoricals': False,
|
| 180 |
+
'is_suitable_for_grouping': False
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
# Analisar tipos de colunas de forma mais robusta
|
| 184 |
+
numeric_cols = []
|
| 185 |
+
date_cols = []
|
| 186 |
+
categorical_cols = []
|
| 187 |
+
|
| 188 |
+
for col in df.columns:
|
| 189 |
+
col_data = df[col]
|
| 190 |
+
|
| 191 |
+
# Verificar se é numérico (incluindo strings que representam números)
|
| 192 |
+
if pd.api.types.is_numeric_dtype(col_data):
|
| 193 |
+
numeric_cols.append(col)
|
| 194 |
+
elif col_data.dtype == 'object':
|
| 195 |
+
# Tentar converter para numérico
|
| 196 |
+
try:
|
| 197 |
+
test_numeric = pd.to_numeric(col_data.astype(str).str.replace(',', '.'), errors='coerce')
|
| 198 |
+
if test_numeric.notna().sum() > len(col_data) * 0.8: # 80% são números válidos
|
| 199 |
+
numeric_cols.append(col)
|
| 200 |
+
else:
|
| 201 |
+
# Verificar se é data
|
| 202 |
+
if any(date_indicator in col.lower() for date_indicator in ['data', 'date', 'time', 'dia', 'mes', 'ano']):
|
| 203 |
+
try:
|
| 204 |
+
pd.to_datetime(col_data.head(3), errors='raise')
|
| 205 |
+
date_cols.append(col)
|
| 206 |
+
except:
|
| 207 |
+
categorical_cols.append(col)
|
| 208 |
+
else:
|
| 209 |
+
categorical_cols.append(col)
|
| 210 |
+
except:
|
| 211 |
+
categorical_cols.append(col)
|
| 212 |
+
elif pd.api.types.is_datetime64_any_dtype(col_data):
|
| 213 |
+
date_cols.append(col)
|
| 214 |
+
else:
|
| 215 |
+
categorical_cols.append(col)
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
'numeric_cols': numeric_cols,
|
| 219 |
+
'date_cols': date_cols,
|
| 220 |
+
'categorical_cols': categorical_cols,
|
| 221 |
+
'total_cols': len(df.columns),
|
| 222 |
+
'has_multiple_numerics': len(numeric_cols) >= 2,
|
| 223 |
+
'has_multiple_categoricals': len(categorical_cols) >= 2,
|
| 224 |
+
'is_suitable_for_grouping': len(categorical_cols) >= 2 or (len(categorical_cols) >= 1 and len(numeric_cols) >= 2)
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
def prepare_data_for_graph_unified(df: pd.DataFrame, graph_type: str, user_query: str = None) -> pd.DataFrame:
|
| 228 |
+
"""
|
| 229 |
+
FUNÇÃO UNIFICADA para preparação de dados - substitui lógica duplicada
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
df: DataFrame original
|
| 233 |
+
graph_type: Tipo de gráfico
|
| 234 |
+
user_query: Pergunta do usuário
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
DataFrame preparado com colunas adequadas para o tipo de gráfico
|
| 238 |
+
"""
|
| 239 |
+
logging.info(f"[GRAPH_GENERATION] 🔧 Preparação UNIFICADA para {graph_type}")
|
| 240 |
+
|
| 241 |
+
if df.empty:
|
| 242 |
+
logging.warning("[GRAPH_GENERATION] DataFrame vazio")
|
| 243 |
+
return df
|
| 244 |
+
|
| 245 |
+
# Fazer cópia para não modificar original
|
| 246 |
+
prepared_df = df.copy()
|
| 247 |
+
|
| 248 |
+
# Analisar estrutura do DataFrame
|
| 249 |
+
structure = analyze_dataframe_structure(prepared_df)
|
| 250 |
+
numeric_cols = structure['numeric_cols']
|
| 251 |
+
date_cols = structure['date_cols']
|
| 252 |
+
categorical_cols = structure['categorical_cols']
|
| 253 |
+
|
| 254 |
+
logging.info(f"[GRAPH_GENERATION] 📊 Estrutura: {len(numeric_cols)} numéricas, {len(date_cols)} datas, {len(categorical_cols)} categóricas")
|
| 255 |
+
|
| 256 |
+
# Preparação específica por tipo de gráfico
|
| 257 |
+
if graph_type in ['line_simple', 'area']:
|
| 258 |
+
return _prepare_for_temporal_graphs(prepared_df, date_cols, numeric_cols, categorical_cols)
|
| 259 |
+
|
| 260 |
+
elif graph_type in ['bar_vertical', 'bar_horizontal']:
|
| 261 |
+
return _prepare_for_simple_bar_graphs(prepared_df, categorical_cols, numeric_cols, graph_type)
|
| 262 |
+
|
| 263 |
+
elif graph_type in ['bar_grouped', 'bar_stacked']:
|
| 264 |
+
return _prepare_for_grouped_graphs(prepared_df, structure, graph_type)
|
| 265 |
+
|
| 266 |
+
elif graph_type in ['pie', 'donut', 'pie_multiple']:
|
| 267 |
+
return _prepare_for_pie_graphs(prepared_df, categorical_cols, numeric_cols, graph_type)
|
| 268 |
+
|
| 269 |
+
elif graph_type == 'multiline':
|
| 270 |
+
return _prepare_for_multiline_graphs(prepared_df, structure)
|
| 271 |
+
|
| 272 |
+
else:
|
| 273 |
+
logging.warning(f"[GRAPH_GENERATION] Tipo {graph_type} não reconhecido, usando preparação básica")
|
| 274 |
+
return _prepare_basic_fallback(prepared_df, categorical_cols, numeric_cols)
|
| 275 |
+
|
| 276 |
+
def _prepare_for_temporal_graphs(df: pd.DataFrame, date_cols: list, numeric_cols: list, categorical_cols: list) -> pd.DataFrame:
|
| 277 |
+
"""Prepara dados para gráficos temporais (linha, área)"""
|
| 278 |
+
if date_cols and numeric_cols:
|
| 279 |
+
# Usar primeira coluna de data e primeira numérica
|
| 280 |
+
x_col, y_col = date_cols[0], numeric_cols[0]
|
| 281 |
+
result_df = df[[x_col, y_col]].sort_values(by=x_col)
|
| 282 |
+
logging.info(f"[GRAPH_GENERATION] 📅 Temporal: {x_col} (data) + {y_col} (numérica)")
|
| 283 |
+
return result_df
|
| 284 |
+
elif categorical_cols and numeric_cols:
|
| 285 |
+
# Usar primeira categórica e primeira numérica
|
| 286 |
+
x_col, y_col = categorical_cols[0], numeric_cols[0]
|
| 287 |
+
result_df = df[[x_col, y_col]].sort_values(by=y_col)
|
| 288 |
+
logging.info(f"[GRAPH_GENERATION] 📊 Categórico: {x_col} + {y_col}")
|
| 289 |
+
return result_df
|
| 290 |
+
else:
|
| 291 |
+
logging.warning("[GRAPH_GENERATION] Dados insuficientes para gráfico temporal")
|
| 292 |
+
return df
|
| 293 |
+
|
| 294 |
+
def _prepare_for_simple_bar_graphs(df: pd.DataFrame, categorical_cols: list, numeric_cols: list, graph_type: str) -> pd.DataFrame:
|
| 295 |
+
"""Prepara dados para gráficos de barras simples"""
|
| 296 |
+
if categorical_cols and numeric_cols:
|
| 297 |
+
x_col, y_col = categorical_cols[0], numeric_cols[0]
|
| 298 |
+
result_df = df[[x_col, y_col]].sort_values(by=y_col, ascending=False)
|
| 299 |
+
|
| 300 |
+
# Limitar categorias para barras verticais
|
| 301 |
+
if graph_type == 'bar_vertical' and len(result_df) > 15:
|
| 302 |
+
result_df = result_df.head(15)
|
| 303 |
+
logging.info(f"[GRAPH_GENERATION] 📊 Limitado a 15 categorias para {graph_type}")
|
| 304 |
+
|
| 305 |
+
logging.info(f"[GRAPH_GENERATION] 📊 Barras simples: {x_col} + {y_col}")
|
| 306 |
+
return result_df
|
| 307 |
+
else:
|
| 308 |
+
logging.warning("[GRAPH_GENERATION] Dados insuficientes para gráfico de barras")
|
| 309 |
+
return df
|
| 310 |
+
|
| 311 |
+
def _prepare_for_grouped_graphs(df: pd.DataFrame, structure: dict, graph_type: str) -> pd.DataFrame:
|
| 312 |
+
"""
|
| 313 |
+
FUNÇÃO CRÍTICA: Prepara dados para gráficos agrupados com lógica inteligente
|
| 314 |
+
"""
|
| 315 |
+
numeric_cols = structure['numeric_cols']
|
| 316 |
+
categorical_cols = structure['categorical_cols']
|
| 317 |
+
has_multiple_numerics = structure['has_multiple_numerics']
|
| 318 |
+
has_multiple_categoricals = structure['has_multiple_categoricals']
|
| 319 |
+
|
| 320 |
+
logging.info(f"[GRAPH_GENERATION] 🎯 Preparando agrupado: {len(numeric_cols)} num, {len(categorical_cols)} cat")
|
| 321 |
+
|
| 322 |
+
if has_multiple_numerics:
|
| 323 |
+
# CENÁRIO 1: Múltiplas numéricas - usar primeira categórica + todas numéricas
|
| 324 |
+
cols_to_keep = [categorical_cols[0]] + numeric_cols
|
| 325 |
+
result_df = df[cols_to_keep]
|
| 326 |
+
logging.info(f"[GRAPH_GENERATION] ✅ Múltiplas numéricas: {cols_to_keep}")
|
| 327 |
+
return result_df
|
| 328 |
+
|
| 329 |
+
elif len(numeric_cols) == 1 and has_multiple_categoricals:
|
| 330 |
+
# CENÁRIO 2: 1 numérica + múltiplas categóricas - AGRUPAMENTO POR COR
|
| 331 |
+
# Usar TODAS as categóricas + a numérica
|
| 332 |
+
cols_to_keep = categorical_cols + numeric_cols
|
| 333 |
+
result_df = df[cols_to_keep]
|
| 334 |
+
logging.info(f"[GRAPH_GENERATION] ✅ Agrupamento por cor: {cols_to_keep}")
|
| 335 |
+
return result_df
|
| 336 |
+
|
| 337 |
+
elif len(numeric_cols) == 1 and len(categorical_cols) == 1:
|
| 338 |
+
# CENÁRIO 3: 1 numérica + 1 categórica - gráfico simples
|
| 339 |
+
cols_to_keep = categorical_cols + numeric_cols
|
| 340 |
+
result_df = df[cols_to_keep]
|
| 341 |
+
logging.info(f"[GRAPH_GENERATION] ⚠️ Dados simples para agrupado: {cols_to_keep}")
|
| 342 |
+
return result_df
|
| 343 |
+
|
| 344 |
+
else:
|
| 345 |
+
# CENÁRIO 4: Dados inadequados
|
| 346 |
+
logging.warning("[GRAPH_GENERATION] ❌ Dados inadequados para gráfico agrupado")
|
| 347 |
+
return df
|
| 348 |
+
|
| 349 |
+
def _prepare_for_pie_graphs(df: pd.DataFrame, categorical_cols: list, numeric_cols: list, graph_type: str) -> pd.DataFrame:
|
| 350 |
+
"""Prepara dados para gráficos de pizza"""
|
| 351 |
+
if categorical_cols and numeric_cols:
|
| 352 |
+
cat_col, val_col = categorical_cols[0], numeric_cols[0]
|
| 353 |
+
|
| 354 |
+
if graph_type == 'pie_multiple' and len(categorical_cols) >= 2:
|
| 355 |
+
# Para pizzas múltiplas, manter 2 categóricas + 1 numérica
|
| 356 |
+
result_df = df[[categorical_cols[0], categorical_cols[1], val_col]]
|
| 357 |
+
logging.info(f"[GRAPH_GENERATION] 🥧 Pizzas múltiplas: {result_df.columns.tolist()}")
|
| 358 |
+
else:
|
| 359 |
+
# Agrupar e somar valores para pizza simples/donut
|
| 360 |
+
result_df = df.groupby(cat_col)[val_col].sum().reset_index()
|
| 361 |
+
result_df = result_df.sort_values(by=val_col, ascending=False)
|
| 362 |
+
|
| 363 |
+
# Limitar a 10 categorias
|
| 364 |
+
if len(result_df) > 10:
|
| 365 |
+
top_9 = result_df.head(9)
|
| 366 |
+
others_sum = result_df.iloc[9:][val_col].sum()
|
| 367 |
+
if others_sum > 0:
|
| 368 |
+
others_row = pd.DataFrame({cat_col: ['Outros'], val_col: [others_sum]})
|
| 369 |
+
result_df = pd.concat([top_9, others_row], ignore_index=True)
|
| 370 |
+
else:
|
| 371 |
+
result_df = top_9
|
| 372 |
+
|
| 373 |
+
logging.info(f"[GRAPH_GENERATION] 🥧 Pizza: {cat_col} + {val_col} ({len(result_df)} categorias)")
|
| 374 |
+
|
| 375 |
+
return result_df
|
| 376 |
+
else:
|
| 377 |
+
logging.warning("[GRAPH_GENERATION] Dados insuficientes para gráfico de pizza")
|
| 378 |
+
return df
|
| 379 |
+
|
| 380 |
+
def _prepare_for_multiline_graphs(df: pd.DataFrame, structure: dict) -> pd.DataFrame:
|
| 381 |
+
"""Prepara dados para gráficos de múltiplas linhas"""
|
| 382 |
+
date_cols = structure['date_cols']
|
| 383 |
+
numeric_cols = structure['numeric_cols']
|
| 384 |
+
categorical_cols = structure['categorical_cols']
|
| 385 |
+
|
| 386 |
+
if date_cols and len(numeric_cols) >= 2:
|
| 387 |
+
# Data + múltiplas numéricas
|
| 388 |
+
cols_to_keep = [date_cols[0]] + numeric_cols
|
| 389 |
+
result_df = df[cols_to_keep].sort_values(by=date_cols[0])
|
| 390 |
+
logging.info(f"[GRAPH_GENERATION] 📈 Multilinhas temporais: {cols_to_keep}")
|
| 391 |
+
return result_df
|
| 392 |
+
elif categorical_cols and len(numeric_cols) >= 2:
|
| 393 |
+
# Categórica + múltiplas numéricas
|
| 394 |
+
cols_to_keep = [categorical_cols[0]] + numeric_cols
|
| 395 |
+
result_df = df[cols_to_keep]
|
| 396 |
+
logging.info(f"[GRAPH_GENERATION] 📈 Multilinhas categóricas: {cols_to_keep}")
|
| 397 |
+
return result_df
|
| 398 |
+
else:
|
| 399 |
+
logging.warning("[GRAPH_GENERATION] Dados insuficientes para multilinhas")
|
| 400 |
+
return df
|
| 401 |
+
|
| 402 |
+
def _prepare_basic_fallback(df: pd.DataFrame, categorical_cols: list, numeric_cols: list) -> pd.DataFrame:
|
| 403 |
+
"""Preparação básica de fallback"""
|
| 404 |
+
if categorical_cols and numeric_cols:
|
| 405 |
+
result_df = df[[categorical_cols[0], numeric_cols[0]]]
|
| 406 |
+
logging.info(f"[GRAPH_GENERATION] 🔄 Fallback básico: {result_df.columns.tolist()}")
|
| 407 |
+
return result_df
|
| 408 |
+
else:
|
| 409 |
+
logging.warning("[GRAPH_GENERATION] Dados inadequados para qualquer gráfico")
|
| 410 |
+
return df
|
| 411 |
+
|
| 412 |
+
def save_plot_to_image() -> Image.Image:
|
| 413 |
+
"""
|
| 414 |
+
Salva o plot atual do matplotlib como imagem PIL
|
| 415 |
+
|
| 416 |
+
Returns:
|
| 417 |
+
Imagem PIL
|
| 418 |
+
"""
|
| 419 |
+
buf = io.BytesIO()
|
| 420 |
+
plt.savefig(buf, format='png', dpi=100, bbox_inches='tight', facecolor='white')
|
| 421 |
+
buf.seek(0)
|
| 422 |
+
img = Image.open(buf)
|
| 423 |
+
plt.close() # Importante: fechar o plot para liberar memória
|
| 424 |
+
return img
|
| 425 |
+
|
| 426 |
+
# ==================== FUNÇÕES DE GERAÇÃO ESPECÍFICAS ====================
|
| 427 |
+
|
| 428 |
+
async def generate_line_simple(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 429 |
+
"""Gera gráfico de linha simples"""
|
| 430 |
+
if len(df.columns) < 2:
|
| 431 |
+
return None
|
| 432 |
+
|
| 433 |
+
x_col, y_col = df.columns[0], df.columns[1]
|
| 434 |
+
is_date = pd.api.types.is_datetime64_any_dtype(df[x_col])
|
| 435 |
+
|
| 436 |
+
plt.figure(figsize=(12, 6))
|
| 437 |
+
|
| 438 |
+
if is_date:
|
| 439 |
+
plt.plot(df[x_col], df[y_col], marker='o', linewidth=2, color=colors[0])
|
| 440 |
+
plt.gcf().autofmt_xdate()
|
| 441 |
+
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%d/%m/%Y'))
|
| 442 |
+
else:
|
| 443 |
+
plt.plot(range(len(df)), df[y_col], marker='o', linewidth=2, color=colors[0])
|
| 444 |
+
plt.xticks(range(len(df)), df[x_col], rotation=45, ha='right')
|
| 445 |
+
|
| 446 |
+
plt.xlabel(x_col)
|
| 447 |
+
plt.ylabel(y_col)
|
| 448 |
+
plt.title(title or f"{y_col} por {x_col}")
|
| 449 |
+
plt.grid(True, linestyle='--', alpha=0.7)
|
| 450 |
+
plt.tight_layout()
|
| 451 |
+
|
| 452 |
+
return save_plot_to_image()
|
| 453 |
+
|
| 454 |
+
async def generate_multiline(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 455 |
+
"""Gera gráfico de múltiplas linhas"""
|
| 456 |
+
if len(df.columns) < 2:
|
| 457 |
+
return None
|
| 458 |
+
|
| 459 |
+
x_col = df.columns[0]
|
| 460 |
+
y_cols = [col for col in df.columns[1:] if pd.api.types.is_numeric_dtype(df[col])]
|
| 461 |
+
|
| 462 |
+
if not y_cols:
|
| 463 |
+
return await generate_line_simple(df, title, colors)
|
| 464 |
+
|
| 465 |
+
is_date = pd.api.types.is_datetime64_any_dtype(df[x_col])
|
| 466 |
+
|
| 467 |
+
plt.figure(figsize=(12, 6))
|
| 468 |
+
|
| 469 |
+
for i, y_col in enumerate(y_cols):
|
| 470 |
+
if is_date:
|
| 471 |
+
plt.plot(df[x_col], df[y_col], marker='o', linewidth=2,
|
| 472 |
+
label=y_col, color=colors[i % len(colors)])
|
| 473 |
+
else:
|
| 474 |
+
plt.plot(range(len(df)), df[y_col], marker='o', linewidth=2,
|
| 475 |
+
label=y_col, color=colors[i % len(colors)])
|
| 476 |
+
|
| 477 |
+
if is_date:
|
| 478 |
+
plt.gcf().autofmt_xdate()
|
| 479 |
+
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%d/%m/%Y'))
|
| 480 |
+
else:
|
| 481 |
+
plt.xticks(range(len(df)), df[x_col], rotation=45, ha='right')
|
| 482 |
+
|
| 483 |
+
plt.xlabel(x_col)
|
| 484 |
+
plt.ylabel("Valores")
|
| 485 |
+
plt.title(title or f"Comparação por {x_col}")
|
| 486 |
+
plt.legend(title="Séries", loc='best')
|
| 487 |
+
plt.grid(True, linestyle='--', alpha=0.7)
|
| 488 |
+
plt.tight_layout()
|
| 489 |
+
|
| 490 |
+
return save_plot_to_image()
|
| 491 |
+
|
| 492 |
+
async def generate_area(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 493 |
+
"""Gera gráfico de área"""
|
| 494 |
+
if len(df.columns) < 2:
|
| 495 |
+
return None
|
| 496 |
+
|
| 497 |
+
x_col, y_col = df.columns[0], df.columns[1]
|
| 498 |
+
is_date = pd.api.types.is_datetime64_any_dtype(df[x_col])
|
| 499 |
+
|
| 500 |
+
plt.figure(figsize=(12, 6))
|
| 501 |
+
|
| 502 |
+
if is_date:
|
| 503 |
+
plt.fill_between(df[x_col], df[y_col], alpha=0.5, color=colors[0])
|
| 504 |
+
plt.plot(df[x_col], df[y_col], color=colors[0], linewidth=2)
|
| 505 |
+
plt.gcf().autofmt_xdate()
|
| 506 |
+
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%d/%m/%Y'))
|
| 507 |
+
else:
|
| 508 |
+
plt.fill_between(range(len(df)), df[y_col], alpha=0.5, color=colors[0])
|
| 509 |
+
plt.plot(range(len(df)), df[y_col], color=colors[0], linewidth=2)
|
| 510 |
+
plt.xticks(range(len(df)), df[x_col], rotation=45, ha='right')
|
| 511 |
+
|
| 512 |
+
plt.xlabel(x_col)
|
| 513 |
+
plt.ylabel(y_col)
|
| 514 |
+
plt.title(title or f"{y_col} por {x_col}")
|
| 515 |
+
plt.grid(True, linestyle='--', alpha=0.7)
|
| 516 |
+
plt.tight_layout()
|
| 517 |
+
|
| 518 |
+
return save_plot_to_image()
|
| 519 |
+
|
| 520 |
+
async def generate_bar_vertical(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 521 |
+
"""Gera gráfico de barras verticais"""
|
| 522 |
+
if len(df.columns) < 2:
|
| 523 |
+
return None
|
| 524 |
+
|
| 525 |
+
x_col, y_col = df.columns[0], df.columns[1]
|
| 526 |
+
|
| 527 |
+
# Preparar dados numéricos - converter strings com vírgula para float
|
| 528 |
+
df_plot = df.copy()
|
| 529 |
+
try:
|
| 530 |
+
if df_plot[y_col].dtype == 'object':
|
| 531 |
+
# Converte strings para números, tratando vírgulas como separador decimal
|
| 532 |
+
df_plot[y_col] = pd.to_numeric(df_plot[y_col].astype(str).str.replace(',', '.'), errors='coerce')
|
| 533 |
+
|
| 534 |
+
# Remove linhas com valores não numéricos
|
| 535 |
+
df_plot = df_plot.dropna(subset=[y_col])
|
| 536 |
+
|
| 537 |
+
if df_plot.empty:
|
| 538 |
+
logging.error(f"[GRAPH_GENERATION] Nenhum valor numérico válido encontrado na coluna {y_col}")
|
| 539 |
+
return None
|
| 540 |
+
|
| 541 |
+
except Exception as e:
|
| 542 |
+
logging.error(f"[GRAPH_GENERATION] Erro ao converter dados para numérico: {e}")
|
| 543 |
+
return None
|
| 544 |
+
|
| 545 |
+
plt.figure(figsize=(12, 8))
|
| 546 |
+
bars = plt.bar(range(len(df_plot)), df_plot[y_col], color=colors[0])
|
| 547 |
+
|
| 548 |
+
# Adicionar valores nas barras
|
| 549 |
+
try:
|
| 550 |
+
max_value = df_plot[y_col].max()
|
| 551 |
+
for i, bar in enumerate(bars):
|
| 552 |
+
height = bar.get_height()
|
| 553 |
+
if isinstance(height, (int, float)) and not pd.isna(height):
|
| 554 |
+
plt.text(bar.get_x() + bar.get_width()/2., height + 0.02 * max_value,
|
| 555 |
+
f'{height:,.0f}', ha='center', fontsize=9)
|
| 556 |
+
except Exception as e:
|
| 557 |
+
logging.warning(f"[GRAPH_GENERATION] Erro ao adicionar valores nas barras: {e}")
|
| 558 |
+
|
| 559 |
+
plt.xlabel(x_col)
|
| 560 |
+
plt.ylabel(y_col)
|
| 561 |
+
plt.title(title or f"{y_col} por {x_col}")
|
| 562 |
+
plt.xticks(range(len(df_plot)), df_plot[x_col], rotation=45, ha='right')
|
| 563 |
+
plt.grid(True, linestyle='--', alpha=0.7, axis='y')
|
| 564 |
+
plt.tight_layout()
|
| 565 |
+
|
| 566 |
+
return save_plot_to_image()
|
| 567 |
+
|
| 568 |
+
async def generate_bar_horizontal(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 569 |
+
"""Gera gráfico de barras horizontais"""
|
| 570 |
+
if len(df.columns) < 2:
|
| 571 |
+
return None
|
| 572 |
+
|
| 573 |
+
x_col, y_col = df.columns[0], df.columns[1]
|
| 574 |
+
|
| 575 |
+
# Preparar dados numéricos - converter strings com vírgula para float
|
| 576 |
+
df_plot = df.copy()
|
| 577 |
+
try:
|
| 578 |
+
if df_plot[y_col].dtype == 'object':
|
| 579 |
+
# Converte strings para números, tratando vírgulas como separador decimal
|
| 580 |
+
df_plot[y_col] = pd.to_numeric(df_plot[y_col].astype(str).str.replace(',', '.'), errors='coerce')
|
| 581 |
+
|
| 582 |
+
# Remove linhas com valores não numéricos
|
| 583 |
+
df_plot = df_plot.dropna(subset=[y_col])
|
| 584 |
+
|
| 585 |
+
if df_plot.empty:
|
| 586 |
+
logging.error(f"[GRAPH_GENERATION] Nenhum valor numérico válido encontrado na coluna {y_col}")
|
| 587 |
+
return None
|
| 588 |
+
|
| 589 |
+
except Exception as e:
|
| 590 |
+
logging.error(f"[GRAPH_GENERATION] Erro ao converter dados para numérico: {e}")
|
| 591 |
+
return None
|
| 592 |
+
|
| 593 |
+
plt.figure(figsize=(12, max(6, len(df_plot) * 0.4)))
|
| 594 |
+
bars = plt.barh(range(len(df_plot)), df_plot[y_col], color=colors[0])
|
| 595 |
+
|
| 596 |
+
# Adicionar valores nas barras
|
| 597 |
+
try:
|
| 598 |
+
max_value = df_plot[y_col].max()
|
| 599 |
+
for i, bar in enumerate(bars):
|
| 600 |
+
width = bar.get_width()
|
| 601 |
+
if isinstance(width, (int, float)) and not pd.isna(width):
|
| 602 |
+
plt.text(width + 0.02 * max_value, bar.get_y() + bar.get_height()/2.,
|
| 603 |
+
f'{width:,.0f}', va='center', fontsize=9)
|
| 604 |
+
except Exception as e:
|
| 605 |
+
logging.warning(f"[GRAPH_GENERATION] Erro ao adicionar valores nas barras: {e}")
|
| 606 |
+
|
| 607 |
+
plt.xlabel(y_col)
|
| 608 |
+
plt.ylabel(x_col)
|
| 609 |
+
plt.title(title or f"{y_col} por {x_col}")
|
| 610 |
+
plt.yticks(range(len(df_plot)), df_plot[x_col])
|
| 611 |
+
plt.grid(True, linestyle='--', alpha=0.7, axis='x')
|
| 612 |
+
plt.tight_layout()
|
| 613 |
+
|
| 614 |
+
return save_plot_to_image()
|
| 615 |
+
|
| 616 |
+
async def generate_bar_grouped(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 617 |
+
"""
|
| 618 |
+
FUNÇÃO REFATORADA: Gera gráfico de barras agrupadas com fallbacks inteligentes
|
| 619 |
+
"""
|
| 620 |
+
logging.info(f"[GRAPH_GENERATION] 🎯 Gerando barras agrupadas REFATORADO. Colunas: {df.columns.tolist()}")
|
| 621 |
+
|
| 622 |
+
if len(df.columns) < 2:
|
| 623 |
+
logging.warning("[GRAPH_GENERATION] ❌ Dados insuficientes para gráfico agrupado")
|
| 624 |
+
return None
|
| 625 |
+
|
| 626 |
+
# Analisar estrutura dos dados
|
| 627 |
+
structure = analyze_dataframe_structure(df)
|
| 628 |
+
numeric_cols = structure['numeric_cols']
|
| 629 |
+
categorical_cols = structure['categorical_cols']
|
| 630 |
+
|
| 631 |
+
logging.info(f"[GRAPH_GENERATION] 📊 Estrutura: {len(numeric_cols)} numéricas, {len(categorical_cols)} categóricas")
|
| 632 |
+
|
| 633 |
+
if not numeric_cols:
|
| 634 |
+
logging.warning("[GRAPH_GENERATION] ❌ Nenhuma coluna numérica encontrada")
|
| 635 |
+
return await generate_bar_vertical(df, title, colors)
|
| 636 |
+
|
| 637 |
+
# DECISÃO INTELIGENTE baseada na estrutura dos dados
|
| 638 |
+
if len(numeric_cols) >= 2:
|
| 639 |
+
# CENÁRIO 1: Múltiplas numéricas - gráfico agrupado tradicional
|
| 640 |
+
return await _generate_multi_numeric_grouped(df, title, colors, categorical_cols[0], numeric_cols)
|
| 641 |
+
|
| 642 |
+
elif len(numeric_cols) == 1 and len(categorical_cols) >= 2:
|
| 643 |
+
# CENÁRIO 2: 1 numérica + múltiplas categóricas - agrupamento por cor
|
| 644 |
+
return await _generate_color_grouped_bars(df, title, colors, categorical_cols, numeric_cols[0])
|
| 645 |
+
|
| 646 |
+
elif len(numeric_cols) == 1 and len(categorical_cols) == 1:
|
| 647 |
+
# CENÁRIO 3: Dados simples - fallback inteligente para barras verticais
|
| 648 |
+
logging.info("[GRAPH_GENERATION] ⚠️ Dados simples, usando barras verticais")
|
| 649 |
+
return await generate_bar_vertical(df, title, colors)
|
| 650 |
+
|
| 651 |
+
else:
|
| 652 |
+
# CENÁRIO 4: Estrutura inadequada
|
| 653 |
+
logging.warning("[GRAPH_GENERATION] ❌ Estrutura de dados inadequada para agrupamento")
|
| 654 |
+
return await generate_bar_vertical(df, title, colors)
|
| 655 |
+
|
| 656 |
+
async def _generate_multi_numeric_grouped(df: pd.DataFrame, title: str, colors, x_col: str, y_cols: list) -> Optional[Image.Image]:
|
| 657 |
+
"""
|
| 658 |
+
Gera gráfico agrupado com múltiplas colunas numéricas (cenário tradicional)
|
| 659 |
+
"""
|
| 660 |
+
logging.info(f"[GRAPH_GENERATION] 📊 Múltiplas numéricas: {x_col} + {y_cols}")
|
| 661 |
+
|
| 662 |
+
# Converter colunas numéricas se necessário
|
| 663 |
+
for col in y_cols:
|
| 664 |
+
if df[col].dtype == 'object':
|
| 665 |
+
df[col] = pd.to_numeric(df[col].astype(str).str.replace(',', '.'), errors='coerce')
|
| 666 |
+
|
| 667 |
+
# Remover linhas com valores NaN
|
| 668 |
+
df_clean = df.dropna(subset=y_cols)
|
| 669 |
+
|
| 670 |
+
if df_clean.empty:
|
| 671 |
+
logging.error("[GRAPH_GENERATION] ❌ Todos os valores são NaN após conversão")
|
| 672 |
+
return None
|
| 673 |
+
|
| 674 |
+
# Verificar diferença de escala entre colunas
|
| 675 |
+
col_ranges = {col: df_clean[col].max() - df_clean[col].min() for col in y_cols}
|
| 676 |
+
max_range = max(col_ranges.values())
|
| 677 |
+
min_range = min(col_ranges.values())
|
| 678 |
+
|
| 679 |
+
if max_range > 0 and min_range > 0 and (max_range / min_range) > 100:
|
| 680 |
+
# Escalas muito diferentes - usar eixos duplos
|
| 681 |
+
logging.info("[GRAPH_GENERATION] 📊 Escalas diferentes, usando eixos duplos")
|
| 682 |
+
return await _generate_dual_axis_chart(df_clean, title, colors, x_col, y_cols[0], y_cols[1])
|
| 683 |
+
|
| 684 |
+
# Gráfico agrupado normal
|
| 685 |
+
x_pos = np.arange(len(df_clean))
|
| 686 |
+
width = 0.8 / len(y_cols)
|
| 687 |
+
|
| 688 |
+
fig, ax = plt.subplots(figsize=(14, 8))
|
| 689 |
+
|
| 690 |
+
for i, col in enumerate(y_cols):
|
| 691 |
+
offset = width * i - width * (len(y_cols) - 1) / 2
|
| 692 |
+
bars = ax.bar(x_pos + offset, df_clean[col], width, label=col,
|
| 693 |
+
color=colors[i % len(colors)], alpha=0.8)
|
| 694 |
+
|
| 695 |
+
# Adicionar valores nas barras
|
| 696 |
+
for bar in bars:
|
| 697 |
+
height = bar.get_height()
|
| 698 |
+
if height > 0:
|
| 699 |
+
ax.text(bar.get_x() + bar.get_width()/2., height + height * 0.02,
|
| 700 |
+
f'{height:.0f}', ha='center', fontsize=8)
|
| 701 |
+
|
| 702 |
+
ax.set_xlabel(x_col)
|
| 703 |
+
ax.set_ylabel('Valores')
|
| 704 |
+
ax.set_title(title or f"Comparação de {', '.join(y_cols)} por {x_col}")
|
| 705 |
+
ax.set_xticks(x_pos)
|
| 706 |
+
ax.set_xticklabels(df_clean[x_col], rotation=45, ha='right')
|
| 707 |
+
ax.legend()
|
| 708 |
+
ax.grid(True, linestyle='--', alpha=0.7, axis='y')
|
| 709 |
+
plt.tight_layout()
|
| 710 |
+
|
| 711 |
+
logging.info(f"[GRAPH_GENERATION] ✅ Gráfico agrupado tradicional criado: {len(y_cols)} métricas")
|
| 712 |
+
return save_plot_to_image()
|
| 713 |
+
|
| 714 |
+
async def _generate_color_grouped_bars(df: pd.DataFrame, title: str, colors, categorical_cols: list, y_col: str) -> Optional[Image.Image]:
|
| 715 |
+
"""
|
| 716 |
+
Gera gráfico agrupado por cor usando múltiplas categóricas (CENÁRIO CRÍTICO)
|
| 717 |
+
"""
|
| 718 |
+
x_col = categorical_cols[0]
|
| 719 |
+
group_col = categorical_cols[1] if len(categorical_cols) > 1 else None
|
| 720 |
+
|
| 721 |
+
logging.info(f"[GRAPH_GENERATION] 🎨 Agrupamento por cor: {x_col} (X) + {y_col} (Y) + {group_col} (cor)")
|
| 722 |
+
|
| 723 |
+
if not group_col:
|
| 724 |
+
logging.warning("[GRAPH_GENERATION] ⚠️ Sem coluna para agrupamento, usando gráfico simples")
|
| 725 |
+
return await generate_bar_vertical(df[[x_col, y_col]], title, colors)
|
| 726 |
+
|
| 727 |
+
# Converter coluna numérica se necessário
|
| 728 |
+
if df[y_col].dtype == 'object':
|
| 729 |
+
df[y_col] = pd.to_numeric(df[y_col].astype(str).str.replace(',', '.'), errors='coerce')
|
| 730 |
+
|
| 731 |
+
# Remover linhas com valores NaN
|
| 732 |
+
df_clean = df.dropna(subset=[y_col])
|
| 733 |
+
|
| 734 |
+
if df_clean.empty:
|
| 735 |
+
logging.error("[GRAPH_GENERATION] ❌ Todos os valores são NaN após conversão")
|
| 736 |
+
return None
|
| 737 |
+
|
| 738 |
+
# Obter categorias únicas
|
| 739 |
+
unique_groups = df_clean[group_col].unique()
|
| 740 |
+
unique_x = df_clean[x_col].unique()
|
| 741 |
+
|
| 742 |
+
logging.info(f"[GRAPH_GENERATION] 🎯 Grupos: {unique_groups} | X: {len(unique_x)} categorias")
|
| 743 |
+
|
| 744 |
+
# Configurar gráfico
|
| 745 |
+
x_pos = np.arange(len(unique_x))
|
| 746 |
+
width = 0.8 / len(unique_groups)
|
| 747 |
+
|
| 748 |
+
fig, ax = plt.subplots(figsize=(14, 8))
|
| 749 |
+
|
| 750 |
+
# Criar barras para cada grupo
|
| 751 |
+
for i, group in enumerate(unique_groups):
|
| 752 |
+
group_data = df_clean[df_clean[group_col] == group]
|
| 753 |
+
|
| 754 |
+
# Criar array de valores para cada posição X
|
| 755 |
+
values = []
|
| 756 |
+
for x_val in unique_x:
|
| 757 |
+
matching_rows = group_data[group_data[x_col] == x_val]
|
| 758 |
+
if not matching_rows.empty:
|
| 759 |
+
values.append(matching_rows[y_col].iloc[0])
|
| 760 |
+
else:
|
| 761 |
+
values.append(0)
|
| 762 |
+
|
| 763 |
+
# Calcular posição das barras
|
| 764 |
+
offset = width * i - width * (len(unique_groups) - 1) / 2
|
| 765 |
+
bars = ax.bar(x_pos + offset, values, width, label=f"{group_col}: {group}",
|
| 766 |
+
color=colors[i % len(colors)], alpha=0.8)
|
| 767 |
+
|
| 768 |
+
# Adicionar valores nas barras
|
| 769 |
+
for bar, value in zip(bars, values):
|
| 770 |
+
if value > 0:
|
| 771 |
+
ax.text(bar.get_x() + bar.get_width()/2., value + value * 0.02,
|
| 772 |
+
f'{value:.0f}', ha='center', fontsize=8)
|
| 773 |
+
|
| 774 |
+
# Configurações do gráfico
|
| 775 |
+
ax.set_xlabel(x_col)
|
| 776 |
+
ax.set_ylabel(y_col)
|
| 777 |
+
ax.set_title(title or f"{y_col} por {x_col} (agrupado por {group_col})")
|
| 778 |
+
ax.set_xticks(x_pos)
|
| 779 |
+
ax.set_xticklabels(unique_x, rotation=45, ha='right')
|
| 780 |
+
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
|
| 781 |
+
ax.grid(True, linestyle='--', alpha=0.7, axis='y')
|
| 782 |
+
plt.tight_layout()
|
| 783 |
+
|
| 784 |
+
logging.info(f"[GRAPH_GENERATION] ✅ Gráfico agrupado por cor criado: {len(unique_groups)} grupos")
|
| 785 |
+
return save_plot_to_image()
|
| 786 |
+
|
| 787 |
+
async def _generate_dual_axis_chart(df: pd.DataFrame, title: str, colors, x_col: str, y1_col: str, y2_col: str) -> Optional[Image.Image]:
|
| 788 |
+
"""
|
| 789 |
+
Gera gráfico com eixos duplos para métricas com escalas diferentes
|
| 790 |
+
"""
|
| 791 |
+
logging.info(f"[GRAPH_GENERATION] 📊 Eixos duplos: {y1_col} (esq) + {y2_col} (dir)")
|
| 792 |
+
|
| 793 |
+
fig, ax1 = plt.subplots(figsize=(14, 8))
|
| 794 |
+
|
| 795 |
+
# Primeiro eixo Y (esquerda)
|
| 796 |
+
x_pos = np.arange(len(df))
|
| 797 |
+
width = 0.35
|
| 798 |
+
|
| 799 |
+
bars1 = ax1.bar(x_pos - width/2, df[y1_col], width, label=y1_col,
|
| 800 |
+
color=colors[0], alpha=0.8)
|
| 801 |
+
ax1.set_xlabel(x_col)
|
| 802 |
+
ax1.set_ylabel(y1_col, color=colors[0])
|
| 803 |
+
ax1.tick_params(axis='y', labelcolor=colors[0])
|
| 804 |
+
|
| 805 |
+
# Segundo eixo Y (direita)
|
| 806 |
+
ax2 = ax1.twinx()
|
| 807 |
+
bars2 = ax2.bar(x_pos + width/2, df[y2_col], width, label=y2_col,
|
| 808 |
+
color=colors[1], alpha=0.8)
|
| 809 |
+
ax2.set_ylabel(y2_col, color=colors[1])
|
| 810 |
+
ax2.tick_params(axis='y', labelcolor=colors[1])
|
| 811 |
+
|
| 812 |
+
# Configurações comuns
|
| 813 |
+
ax1.set_xticks(x_pos)
|
| 814 |
+
ax1.set_xticklabels(df[x_col], rotation=45, ha='right')
|
| 815 |
+
ax1.grid(True, linestyle='--', alpha=0.7, axis='y')
|
| 816 |
+
|
| 817 |
+
# Adicionar valores nas barras
|
| 818 |
+
for bar in bars1:
|
| 819 |
+
height = bar.get_height()
|
| 820 |
+
if height > 0:
|
| 821 |
+
ax1.text(bar.get_x() + bar.get_width()/2., height + height * 0.02,
|
| 822 |
+
f'{height:.0f}', ha='center', fontsize=8)
|
| 823 |
+
|
| 824 |
+
for bar in bars2:
|
| 825 |
+
height = bar.get_height()
|
| 826 |
+
if height > 0:
|
| 827 |
+
ax2.text(bar.get_x() + bar.get_width()/2., height + height * 0.02,
|
| 828 |
+
f'{height:.0f}', ha='center', fontsize=8)
|
| 829 |
+
|
| 830 |
+
plt.title(title or f"{y1_col} e {y2_col} por {x_col}")
|
| 831 |
+
plt.tight_layout()
|
| 832 |
+
|
| 833 |
+
logging.info(f"[GRAPH_GENERATION] ✅ Gráfico com eixos duplos criado: {y1_col} + {y2_col}")
|
| 834 |
+
return save_plot_to_image()
|
| 835 |
+
|
| 836 |
+
# Função removida - substituída pela nova lógica unificada
|
| 837 |
+
|
| 838 |
+
# Função removida - substituída pela nova lógica unificada em _generate_color_grouped_bars()
|
| 839 |
+
|
| 840 |
+
async def generate_bar_stacked(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 841 |
+
"""Gera gráfico de barras empilhadas"""
|
| 842 |
+
if len(df.columns) < 3:
|
| 843 |
+
return await generate_bar_vertical(df, title, colors)
|
| 844 |
+
|
| 845 |
+
x_col = df.columns[0]
|
| 846 |
+
y_cols = [col for col in df.columns[1:] if pd.api.types.is_numeric_dtype(df[col])]
|
| 847 |
+
|
| 848 |
+
if not y_cols:
|
| 849 |
+
return await generate_bar_vertical(df, title, colors)
|
| 850 |
+
|
| 851 |
+
fig, ax = plt.subplots(figsize=(12, 8))
|
| 852 |
+
bottom = np.zeros(len(df))
|
| 853 |
+
|
| 854 |
+
for i, col in enumerate(y_cols):
|
| 855 |
+
bars = ax.bar(range(len(df)), df[col], bottom=bottom, label=col, color=colors[i % len(colors)])
|
| 856 |
+
|
| 857 |
+
# Adicionar valores nas barras
|
| 858 |
+
for j, bar in enumerate(bars):
|
| 859 |
+
height = bar.get_height()
|
| 860 |
+
if isinstance(height, (int, float)) and height > 0:
|
| 861 |
+
ax.text(bar.get_x() + bar.get_width()/2., bottom[j] + height/2,
|
| 862 |
+
f'{height:.2f}', ha='center', va='center', fontsize=8, color='white')
|
| 863 |
+
|
| 864 |
+
bottom += df[col].fillna(0)
|
| 865 |
+
|
| 866 |
+
ax.set_xlabel(x_col)
|
| 867 |
+
ax.set_ylabel('Valores')
|
| 868 |
+
ax.set_title(title or f"Distribuição por {x_col}")
|
| 869 |
+
ax.set_xticks(range(len(df)))
|
| 870 |
+
ax.set_xticklabels(df[x_col], rotation=45, ha='right')
|
| 871 |
+
ax.legend()
|
| 872 |
+
plt.tight_layout()
|
| 873 |
+
|
| 874 |
+
return save_plot_to_image()
|
| 875 |
+
|
| 876 |
+
async def generate_pie(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 877 |
+
"""Gera gráfico de pizza"""
|
| 878 |
+
if len(df.columns) < 2:
|
| 879 |
+
return None
|
| 880 |
+
|
| 881 |
+
label_col, value_col = df.columns[0], df.columns[1]
|
| 882 |
+
|
| 883 |
+
# Preparar dados numéricos - converter strings com vírgula para float
|
| 884 |
+
df_plot = df.copy()
|
| 885 |
+
try:
|
| 886 |
+
if df_plot[value_col].dtype == 'object':
|
| 887 |
+
# Converte strings para números, tratando vírgulas como separador decimal
|
| 888 |
+
df_plot[value_col] = pd.to_numeric(df_plot[value_col].astype(str).str.replace(',', '.'), errors='coerce')
|
| 889 |
+
|
| 890 |
+
# Remove linhas com valores não numéricos, negativos ou zero
|
| 891 |
+
df_plot = df_plot.dropna(subset=[value_col])
|
| 892 |
+
df_plot = df_plot[df_plot[value_col] > 0]
|
| 893 |
+
|
| 894 |
+
if df_plot.empty:
|
| 895 |
+
logging.error(f"[GRAPH_GENERATION] Nenhum valor numérico positivo encontrado na coluna {value_col}")
|
| 896 |
+
return await generate_bar_vertical(df, title, colors)
|
| 897 |
+
|
| 898 |
+
except Exception as e:
|
| 899 |
+
logging.error(f"[GRAPH_GENERATION] Erro ao converter dados para numérico: {e}")
|
| 900 |
+
return await generate_bar_vertical(df, title, colors)
|
| 901 |
+
|
| 902 |
+
plt.figure(figsize=(10, 10))
|
| 903 |
+
|
| 904 |
+
# Calcular percentuais para os rótulos
|
| 905 |
+
total = df_plot[value_col].sum()
|
| 906 |
+
labels = [f'{label} ({val:,.0f}, {val/total:.1%})' for label, val in zip(df_plot[label_col], df_plot[value_col])]
|
| 907 |
+
|
| 908 |
+
plt.pie(df_plot[value_col], labels=labels, autopct='%1.1f%%',
|
| 909 |
+
startangle=90, shadow=False, colors=colors[:len(df_plot)])
|
| 910 |
+
|
| 911 |
+
plt.axis('equal')
|
| 912 |
+
plt.title(title or f"Distribuição de {value_col} por {label_col}")
|
| 913 |
+
plt.tight_layout()
|
| 914 |
+
|
| 915 |
+
return save_plot_to_image()
|
| 916 |
+
|
| 917 |
+
async def generate_donut(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 918 |
+
"""Gera gráfico de donut"""
|
| 919 |
+
if len(df.columns) < 2:
|
| 920 |
+
return None
|
| 921 |
+
|
| 922 |
+
label_col, value_col = df.columns[0], df.columns[1]
|
| 923 |
+
|
| 924 |
+
# Preparar dados numéricos - converter strings com vírgula para float
|
| 925 |
+
df_plot = df.copy()
|
| 926 |
+
try:
|
| 927 |
+
if df_plot[value_col].dtype == 'object':
|
| 928 |
+
# Converte strings para números, tratando vírgulas como separador decimal
|
| 929 |
+
df_plot[value_col] = pd.to_numeric(df_plot[value_col].astype(str).str.replace(',', '.'), errors='coerce')
|
| 930 |
+
|
| 931 |
+
# Remove linhas com valores não numéricos, negativos ou zero
|
| 932 |
+
df_plot = df_plot.dropna(subset=[value_col])
|
| 933 |
+
df_plot = df_plot[df_plot[value_col] > 0]
|
| 934 |
+
|
| 935 |
+
if df_plot.empty:
|
| 936 |
+
logging.error(f"[GRAPH_GENERATION] Nenhum valor numérico positivo encontrado na coluna {value_col}")
|
| 937 |
+
return await generate_bar_vertical(df, title, colors)
|
| 938 |
+
|
| 939 |
+
except Exception as e:
|
| 940 |
+
logging.error(f"[GRAPH_GENERATION] Erro ao converter dados para numérico: {e}")
|
| 941 |
+
return await generate_bar_vertical(df, title, colors)
|
| 942 |
+
|
| 943 |
+
plt.figure(figsize=(10, 10))
|
| 944 |
+
|
| 945 |
+
# Calcular percentuais para os rótulos
|
| 946 |
+
total = df_plot[value_col].sum()
|
| 947 |
+
labels = [f'{label} ({val:,.0f}, {val/total:.1%})' for label, val in zip(df_plot[label_col], df_plot[value_col])]
|
| 948 |
+
|
| 949 |
+
# Criar gráfico de donut (pizza com círculo central)
|
| 950 |
+
plt.pie(df_plot[value_col], labels=labels, autopct='%1.1f%%',
|
| 951 |
+
startangle=90, shadow=False, colors=colors[:len(df_plot)],
|
| 952 |
+
wedgeprops=dict(width=0.5)) # Largura do anel
|
| 953 |
+
|
| 954 |
+
plt.axis('equal')
|
| 955 |
+
plt.title(title or f"Distribuição de {value_col} por {label_col}")
|
| 956 |
+
plt.tight_layout()
|
| 957 |
+
|
| 958 |
+
return save_plot_to_image()
|
| 959 |
+
|
| 960 |
+
async def generate_pie_multiple(df: pd.DataFrame, title: str, colors) -> Optional[Image.Image]:
|
| 961 |
+
"""Gera múltiplos gráficos de pizza"""
|
| 962 |
+
if len(df.columns) < 3:
|
| 963 |
+
return await generate_pie(df, title, colors)
|
| 964 |
+
|
| 965 |
+
cat1, cat2, val_col = df.columns[0], df.columns[1], df.columns[2]
|
| 966 |
+
|
| 967 |
+
# Verificar se o valor é numérico
|
| 968 |
+
if not pd.api.types.is_numeric_dtype(df[val_col]):
|
| 969 |
+
return await generate_bar_grouped(df, title, colors)
|
| 970 |
+
|
| 971 |
+
# Agrupar dados
|
| 972 |
+
grouped = df.groupby([cat1, cat2])[val_col].sum().unstack().fillna(0)
|
| 973 |
+
|
| 974 |
+
# Determinar layout da grade
|
| 975 |
+
n_groups = len(grouped)
|
| 976 |
+
if n_groups == 0:
|
| 977 |
+
return None
|
| 978 |
+
|
| 979 |
+
cols = min(3, n_groups) # Máximo 3 colunas
|
| 980 |
+
rows = (n_groups + cols - 1) // cols # Arredondar para cima
|
| 981 |
+
|
| 982 |
+
# Criar subplots
|
| 983 |
+
fig, axes = plt.subplots(rows, cols, figsize=(15, 5 * rows))
|
| 984 |
+
if rows == 1 and cols == 1:
|
| 985 |
+
axes = np.array([axes]) # Garantir que axes seja um array
|
| 986 |
+
axes = axes.flatten()
|
| 987 |
+
|
| 988 |
+
# Plotar cada pizza
|
| 989 |
+
for i, (group_name, group_data) in enumerate(grouped.iterrows()):
|
| 990 |
+
if i < len(axes):
|
| 991 |
+
# Remover valores zero
|
| 992 |
+
data = group_data[group_data > 0]
|
| 993 |
+
|
| 994 |
+
if not data.empty:
|
| 995 |
+
# Calcular percentuais
|
| 996 |
+
total = data.sum()
|
| 997 |
+
|
| 998 |
+
# Criar rótulos com valores e percentuais
|
| 999 |
+
labels = [f'{idx} ({val:.2f}, {val/total:.1%})' for idx, val in data.items()]
|
| 1000 |
+
|
| 1001 |
+
# Plotar pizza
|
| 1002 |
+
axes[i].pie(data, labels=labels, autopct='%1.1f%%',
|
| 1003 |
+
startangle=90, colors=colors[:len(data)])
|
| 1004 |
+
axes[i].set_title(f"{group_name}")
|
| 1005 |
+
axes[i].axis('equal')
|
| 1006 |
+
|
| 1007 |
+
# Esconder eixos não utilizados
|
| 1008 |
+
for j in range(i + 1, len(axes)):
|
| 1009 |
+
axes[j].axis('off')
|
| 1010 |
+
|
| 1011 |
+
plt.suptitle(title or f"Distribuição de {val_col} por {cat2} para cada {cat1}", fontsize=16)
|
| 1012 |
+
plt.tight_layout()
|
| 1013 |
+
plt.subplots_adjust(top=0.9)
|
| 1014 |
+
|
| 1015 |
+
return save_plot_to_image()
|
nodes/graph_selection_node.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para seleção do tipo de gráfico usando LLM - REFATORADO COMPLETO
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
+
|
| 9 |
+
from agents.tools import (
|
| 10 |
+
generate_graph_type_context,
|
| 11 |
+
extract_sql_query_from_response
|
| 12 |
+
)
|
| 13 |
+
from utils.config import OPENAI_API_KEY
|
| 14 |
+
from langchain_openai import ChatOpenAI
|
| 15 |
+
from utils.object_manager import get_object_manager
|
| 16 |
+
|
| 17 |
+
# Mapeamento DIRETO no arquivo para evitar problemas externos
|
| 18 |
+
GRAPH_TYPE_MAPPING = {
|
| 19 |
+
"1": "line_simple",
|
| 20 |
+
"2": "multiline",
|
| 21 |
+
"3": "area",
|
| 22 |
+
"4": "bar_vertical",
|
| 23 |
+
"5": "bar_horizontal",
|
| 24 |
+
"6": "bar_grouped",
|
| 25 |
+
"7": "bar_stacked",
|
| 26 |
+
"8": "pie",
|
| 27 |
+
"9": "donut",
|
| 28 |
+
"10": "pie_multiple"
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async def graph_selection_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 32 |
+
"""
|
| 33 |
+
Nó REFATORADO para seleção do tipo de gráfico usando LLM
|
| 34 |
+
"""
|
| 35 |
+
logging.info("[GRAPH_SELECTION_NEW] 🚀 Iniciando seleção REFATORADA")
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
# 1. Verificações básicas
|
| 39 |
+
if state.get("query_type") != "sql_query_graphic":
|
| 40 |
+
logging.info("[GRAPH_SELECTION_NEW] Query não requer gráfico")
|
| 41 |
+
return state
|
| 42 |
+
|
| 43 |
+
# 2. Obter SQL query
|
| 44 |
+
sql_query = state.get("sql_query_extracted")
|
| 45 |
+
if not sql_query:
|
| 46 |
+
sql_query = extract_sql_query_from_response(state.get("response", ""))
|
| 47 |
+
|
| 48 |
+
if not sql_query:
|
| 49 |
+
logging.error("[GRAPH_SELECTION_NEW] ❌ SQL query não encontrada")
|
| 50 |
+
state.update({"graph_error": "SQL query não encontrada", "graph_generated": False})
|
| 51 |
+
return state
|
| 52 |
+
|
| 53 |
+
# 3. Obter dados
|
| 54 |
+
obj_manager = get_object_manager()
|
| 55 |
+
engine = obj_manager.get_engine(state.get("engine_id"))
|
| 56 |
+
if not engine:
|
| 57 |
+
logging.error("[GRAPH_SELECTION_NEW] ❌ Engine não encontrada")
|
| 58 |
+
state.update({"graph_error": "Engine não encontrada", "graph_generated": False})
|
| 59 |
+
return state
|
| 60 |
+
|
| 61 |
+
# 4. Executar query
|
| 62 |
+
try:
|
| 63 |
+
df_result = pd.read_sql_query(sql_query, engine)
|
| 64 |
+
if df_result.empty:
|
| 65 |
+
logging.error("[GRAPH_SELECTION_NEW] ❌ Dados vazios")
|
| 66 |
+
state.update({"graph_error": "Dados vazios", "graph_generated": False})
|
| 67 |
+
return state
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logging.error(f"[GRAPH_SELECTION_NEW] ❌ Erro na query: {e}")
|
| 70 |
+
state.update({"graph_error": f"Erro na query: {e}", "graph_generated": False})
|
| 71 |
+
return state
|
| 72 |
+
|
| 73 |
+
# 5. Preparar contexto
|
| 74 |
+
user_query = state.get("user_input", "")
|
| 75 |
+
df_sample = df_result.head(3)
|
| 76 |
+
graph_context = generate_graph_type_context(user_query, sql_query, df_result.columns.tolist(), df_sample)
|
| 77 |
+
|
| 78 |
+
# 6. Chamar LLM de forma LIMPA
|
| 79 |
+
graph_type = await call_llm_for_graph_selection(graph_context, user_query)
|
| 80 |
+
|
| 81 |
+
logging.error(f"🎯 [RESULTADO_FINAL] Tipo selecionado: '{graph_type}'")
|
| 82 |
+
|
| 83 |
+
# 7. Armazenar resultado
|
| 84 |
+
graph_data_id = obj_manager.store_object(df_result, "graph_data")
|
| 85 |
+
state.update({
|
| 86 |
+
"graph_type": graph_type,
|
| 87 |
+
"graph_data": {
|
| 88 |
+
"data_id": graph_data_id,
|
| 89 |
+
"columns": df_result.columns.tolist(),
|
| 90 |
+
"rows": len(df_result),
|
| 91 |
+
"sample": df_sample.to_dict()
|
| 92 |
+
},
|
| 93 |
+
"graph_error": None
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
return state
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logging.error(f"[GRAPH_SELECTION_NEW] ❌ Erro geral: {e}")
|
| 100 |
+
state.update({"graph_error": f"Erro geral: {e}", "graph_generated": False})
|
| 101 |
+
return state
|
| 102 |
+
|
| 103 |
+
async def call_llm_for_graph_selection(graph_context: str, user_query: str) -> str:
|
| 104 |
+
"""
|
| 105 |
+
Função NOVA e LIMPA para chamar LLM sem interferências
|
| 106 |
+
"""
|
| 107 |
+
logging.error("🔥 [LLM_CALL] Iniciando chamada LIMPA da LLM")
|
| 108 |
+
|
| 109 |
+
# Verificação básica
|
| 110 |
+
if not OPENAI_API_KEY:
|
| 111 |
+
logging.error("🔥 [LLM_CALL] OpenAI não configurada")
|
| 112 |
+
return "line_simple"
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
# Criar LLM com configuração limpa
|
| 116 |
+
llm = ChatOpenAI(
|
| 117 |
+
model="gpt-4o",
|
| 118 |
+
temperature=0,
|
| 119 |
+
max_tokens=5,
|
| 120 |
+
timeout=30
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# Log do contexto
|
| 124 |
+
logging.error("🔥 [LLM_CALL] Contexto enviado:")
|
| 125 |
+
logging.error(f"'{graph_context}...'")
|
| 126 |
+
|
| 127 |
+
# Agora a pergunta real
|
| 128 |
+
real_response = llm.invoke(graph_context)
|
| 129 |
+
real_content = real_response.content.strip()
|
| 130 |
+
|
| 131 |
+
logging.error(f"🔥 [LLM_CALL] Resposta REAL: '{real_content}'")
|
| 132 |
+
|
| 133 |
+
# Extrair número da resposta
|
| 134 |
+
number_match = re.search(r'\b([1-9]|10)\b', real_content)
|
| 135 |
+
if number_match:
|
| 136 |
+
number = number_match.group(0)
|
| 137 |
+
graph_type = GRAPH_TYPE_MAPPING.get(number, "line_simple")
|
| 138 |
+
logging.error(f"🔥 [LLM_CALL] Número: {number} → Tipo: {graph_type}")
|
| 139 |
+
return graph_type
|
| 140 |
+
else:
|
| 141 |
+
logging.error(f"🔥 [LLM_CALL] Número não encontrado em: '{real_content}'")
|
| 142 |
+
return "line_simple"
|
| 143 |
+
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logging.error(f"🔥 [LLM_CALL] ERRO: {e}")
|
| 146 |
+
return "line_simple"
|
| 147 |
+
|
nodes/processing_node.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para processamento de contexto inicial usando Processing Agent
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from typing import Dict, Any
|
| 7 |
+
|
| 8 |
+
from agents.processing_agent import ProcessingAgentManager
|
| 9 |
+
from agents.tools import prepare_processing_context
|
| 10 |
+
from utils.object_manager import get_object_manager
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def process_initial_context_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 14 |
+
"""
|
| 15 |
+
Nó para processar contexto inicial com Processing Agent (opcional)
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
state: Estado atual do agente
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
Estado atualizado com contexto processado
|
| 22 |
+
"""
|
| 23 |
+
# Verifica se o processing está habilitado
|
| 24 |
+
processing_enabled = state.get("processing_enabled", False)
|
| 25 |
+
logging.info(f"[PROCESSING NODE] Processing enabled: {processing_enabled}")
|
| 26 |
+
|
| 27 |
+
if not processing_enabled:
|
| 28 |
+
logging.info("[PROCESSING NODE] Processing Agent desabilitado - pulando nó")
|
| 29 |
+
return state
|
| 30 |
+
|
| 31 |
+
logging.info("[PROCESSING NODE] ===== INICIANDO NÓ DE PROCESSAMENTO =====")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
user_input = state.get("user_input", "")
|
| 35 |
+
processing_model = state.get("processing_model", "gpt-4o-mini")
|
| 36 |
+
|
| 37 |
+
logging.info(f"[PROCESSING NODE] Entrada do usuário: {user_input[:100]}...")
|
| 38 |
+
logging.info(f"[PROCESSING NODE] Modelo selecionado: {processing_model}")
|
| 39 |
+
|
| 40 |
+
if not user_input:
|
| 41 |
+
logging.warning("[PROCESSING NODE] Entrada do usuário não disponível")
|
| 42 |
+
return state
|
| 43 |
+
|
| 44 |
+
# Acessa diretamente o banco de dados para criar amostra
|
| 45 |
+
obj_manager = get_object_manager()
|
| 46 |
+
|
| 47 |
+
# Usa os IDs do GraphManager (que são globais)
|
| 48 |
+
try:
|
| 49 |
+
# Acessa diretamente os IDs do GraphManager através do ObjectManager
|
| 50 |
+
# Pega o primeiro engine e database disponíveis (assumindo que há apenas um)
|
| 51 |
+
engines = obj_manager._engines
|
| 52 |
+
databases = obj_manager._databases
|
| 53 |
+
|
| 54 |
+
if not engines or not databases:
|
| 55 |
+
logging.error("[PROCESSING NODE] Nenhum engine ou database encontrado no ObjectManager")
|
| 56 |
+
return state
|
| 57 |
+
|
| 58 |
+
# Pega o primeiro engine e database disponíveis
|
| 59 |
+
engine_id = list(engines.keys())[0]
|
| 60 |
+
db_id = list(databases.keys())[0]
|
| 61 |
+
|
| 62 |
+
engine = engines[engine_id]
|
| 63 |
+
database = databases[db_id]
|
| 64 |
+
|
| 65 |
+
logging.info(f"[PROCESSING NODE] Usando engine {engine_id} e database {db_id}")
|
| 66 |
+
|
| 67 |
+
# Cria amostra diretamente do banco
|
| 68 |
+
import sqlalchemy as sa
|
| 69 |
+
with engine.connect() as conn:
|
| 70 |
+
# Obtém amostra de dados (10 linhas)
|
| 71 |
+
result = conn.execute(sa.text("SELECT * FROM tabela LIMIT 10"))
|
| 72 |
+
columns = result.keys()
|
| 73 |
+
rows = result.fetchall()
|
| 74 |
+
|
| 75 |
+
# Converte para DataFrame
|
| 76 |
+
db_sample = pd.DataFrame(rows, columns=columns)
|
| 77 |
+
|
| 78 |
+
logging.info(f"[PROCESSING NODE] Amostra criada diretamente do banco: {db_sample.shape[0]} linhas, {db_sample.shape[1]} colunas")
|
| 79 |
+
logging.info(f"[PROCESSING NODE] Colunas: {list(db_sample.columns)}")
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logging.error(f"[PROCESSING NODE] Erro ao acessar banco de dados: {e}")
|
| 83 |
+
logging.error(f"[PROCESSING NODE] Detalhes do erro: {str(e)}")
|
| 84 |
+
return state
|
| 85 |
+
|
| 86 |
+
# Recupera ou cria Processing Agent
|
| 87 |
+
processing_agent_id = state.get("processing_agent_id")
|
| 88 |
+
|
| 89 |
+
if processing_agent_id:
|
| 90 |
+
processing_agent = obj_manager.get_processing_agent(processing_agent_id)
|
| 91 |
+
# Verifica se precisa recriar com modelo diferente
|
| 92 |
+
if processing_agent and processing_agent.model_name != processing_model:
|
| 93 |
+
logging.info(f"[PROCESSING NODE] Recriando Processing Agent com modelo {processing_model}")
|
| 94 |
+
processing_agent.recreate_llm(processing_model)
|
| 95 |
+
else:
|
| 96 |
+
logging.info(f"[PROCESSING NODE] Reutilizando Processing Agent existente com modelo {processing_agent.model_name}")
|
| 97 |
+
else:
|
| 98 |
+
# Cria novo Processing Agent
|
| 99 |
+
logging.info(f"[PROCESSING NODE] Criando novo Processing Agent com modelo {processing_model}")
|
| 100 |
+
processing_agent = ProcessingAgentManager(processing_model)
|
| 101 |
+
processing_agent_id = obj_manager.store_processing_agent(processing_agent)
|
| 102 |
+
state["processing_agent_id"] = processing_agent_id
|
| 103 |
+
logging.info(f"[PROCESSING NODE] Novo Processing Agent criado e armazenado com ID: {processing_agent_id}")
|
| 104 |
+
|
| 105 |
+
# Prepara contexto para o Processing Agent
|
| 106 |
+
processing_context = prepare_processing_context(user_input, db_sample)
|
| 107 |
+
|
| 108 |
+
logging.info(f"[PROCESSING NODE] ===== CONTEXTO PARA PRIMEIRA LLM =====")
|
| 109 |
+
logging.info(f"{processing_context}")
|
| 110 |
+
logging.info(f"[PROCESSING NODE] ===== FIM DO CONTEXTO =====")
|
| 111 |
+
|
| 112 |
+
# Executa processamento
|
| 113 |
+
processing_result = await processing_agent.process_context(processing_context)
|
| 114 |
+
|
| 115 |
+
# Log da resposta da primeira LLM
|
| 116 |
+
logging.info(f"[PROCESSING NODE] ===== RESPOSTA DA PRIMEIRA LLM =====")
|
| 117 |
+
logging.info(f"{processing_result.get('output', 'Sem resposta')}")
|
| 118 |
+
logging.info(f"[PROCESSING NODE] ===== FIM DA RESPOSTA =====")
|
| 119 |
+
|
| 120 |
+
if processing_result["success"]:
|
| 121 |
+
# Extrai query sugerida e observações
|
| 122 |
+
suggested_query = processing_result.get("suggested_query", "")
|
| 123 |
+
query_observations = processing_result.get("query_observations", "")
|
| 124 |
+
|
| 125 |
+
# Atualiza estado com resultados do processamento
|
| 126 |
+
state.update({
|
| 127 |
+
"suggested_query": suggested_query,
|
| 128 |
+
"query_observations": query_observations,
|
| 129 |
+
"processing_result": processing_result,
|
| 130 |
+
"processing_success": True
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
# Log simples do resultado
|
| 134 |
+
if suggested_query:
|
| 135 |
+
logging.info(f"[PROCESSING NODE] ✅ Query SQL extraída com sucesso")
|
| 136 |
+
logging.info(f"[PROCESSING NODE] ✅ Observações extraídas: {len(query_observations)} caracteres")
|
| 137 |
+
logging.info(f"[PROCESSING NODE] 🎯 Query será incluída no contexto do SQL Agent")
|
| 138 |
+
else:
|
| 139 |
+
logging.warning(f"[PROCESSING NODE] ❌ Nenhuma query foi extraída - agente SQL funcionará normalmente")
|
| 140 |
+
|
| 141 |
+
else:
|
| 142 |
+
# Em caso de erro, continua sem processamento
|
| 143 |
+
error_msg = processing_result.get("output", "Erro desconhecido")
|
| 144 |
+
logging.error(f"[PROCESSING] Erro no processamento: {error_msg}")
|
| 145 |
+
|
| 146 |
+
state.update({
|
| 147 |
+
"suggested_query": "",
|
| 148 |
+
"query_observations": "",
|
| 149 |
+
"processing_result": processing_result,
|
| 150 |
+
"processing_success": False,
|
| 151 |
+
"processing_error": error_msg
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
except Exception as e:
|
| 155 |
+
error_msg = f"Erro no nó de processamento: {e}"
|
| 156 |
+
logging.error(f"[PROCESSING] {error_msg}")
|
| 157 |
+
|
| 158 |
+
# Em caso de erro, continua sem processamento
|
| 159 |
+
state.update({
|
| 160 |
+
"suggested_query": "",
|
| 161 |
+
"query_observations": "",
|
| 162 |
+
"processing_success": False,
|
| 163 |
+
"processing_error": error_msg
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
return state
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def should_use_processing(state: Dict[str, Any]) -> str:
|
| 170 |
+
"""
|
| 171 |
+
Determina se deve usar o Processing Agent
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
state: Estado atual
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
Nome do próximo nó
|
| 178 |
+
"""
|
| 179 |
+
if state.get("processing_enabled", False):
|
| 180 |
+
return "process_initial_context"
|
| 181 |
+
else:
|
| 182 |
+
return "prepare_context"
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
async def validate_processing_input_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 186 |
+
"""
|
| 187 |
+
Valida entrada para o Processing Agent
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
state: Estado atual
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
Estado validado
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
logging.info("[PROCESSING VALIDATION] ===== VALIDANDO ENTRADA PARA PROCESSING AGENT =====")
|
| 197 |
+
|
| 198 |
+
# Verifica se processing está habilitado
|
| 199 |
+
processing_enabled = state.get("processing_enabled", False)
|
| 200 |
+
logging.info(f"[PROCESSING VALIDATION] Processing habilitado: {processing_enabled}")
|
| 201 |
+
|
| 202 |
+
if not processing_enabled:
|
| 203 |
+
logging.info("[PROCESSING VALIDATION] Processing desabilitado - pulando validação")
|
| 204 |
+
return state
|
| 205 |
+
|
| 206 |
+
# Valida modelo de processamento
|
| 207 |
+
processing_model = state.get("processing_model", "")
|
| 208 |
+
logging.info(f"[PROCESSING VALIDATION] Modelo especificado: '{processing_model}'")
|
| 209 |
+
|
| 210 |
+
if not processing_model:
|
| 211 |
+
logging.warning("[PROCESSING VALIDATION] Modelo de processamento não especificado, usando padrão")
|
| 212 |
+
state["processing_model"] = "gpt-4o-mini"
|
| 213 |
+
logging.info(f"[PROCESSING VALIDATION] Modelo padrão definido: gpt-4o-mini")
|
| 214 |
+
|
| 215 |
+
# Valida entrada do usuário
|
| 216 |
+
user_input = state.get("user_input", "")
|
| 217 |
+
if not user_input or not user_input.strip():
|
| 218 |
+
logging.error("[PROCESSING VALIDATION] Entrada do usuário vazia - desabilitando processing")
|
| 219 |
+
state["processing_enabled"] = False
|
| 220 |
+
return state
|
| 221 |
+
|
| 222 |
+
logging.info(f"[PROCESSING VALIDATION] Validação concluída com sucesso")
|
| 223 |
+
logging.info(f"[PROCESSING VALIDATION] Modelo final: {state['processing_model']}")
|
| 224 |
+
logging.info(f"[PROCESSING VALIDATION] Entrada: {user_input[:100]}...")
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logging.error(f"[PROCESSING VALIDATION] Erro na validação: {e}")
|
| 228 |
+
state["processing_enabled"] = False
|
| 229 |
+
|
| 230 |
+
return state
|
nodes/query_node.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para processamento de consultas SQL
|
| 3 |
+
"""
|
| 4 |
+
import time
|
| 5 |
+
import logging
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from typing import Dict, Any, TypedDict
|
| 8 |
+
|
| 9 |
+
from agents.tools import is_greeting, detect_query_type, prepare_sql_context
|
| 10 |
+
from agents.sql_agent import SQLAgentManager
|
| 11 |
+
from utils.object_manager import get_object_manager
|
| 12 |
+
|
| 13 |
+
class QueryState(TypedDict):
|
| 14 |
+
"""Estado para processamento de consultas"""
|
| 15 |
+
user_input: str
|
| 16 |
+
selected_model: str
|
| 17 |
+
response: str
|
| 18 |
+
execution_time: float
|
| 19 |
+
error: str
|
| 20 |
+
intermediate_steps: list
|
| 21 |
+
llama_instruction: str
|
| 22 |
+
sql_result: dict
|
| 23 |
+
|
| 24 |
+
async def process_user_query_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 25 |
+
"""
|
| 26 |
+
Nó principal para processar consulta do usuário
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
state: Estado atual com entrada do usuário
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Estado atualizado com resposta processada
|
| 33 |
+
"""
|
| 34 |
+
start_time = time.time()
|
| 35 |
+
user_input = state["user_input"]
|
| 36 |
+
selected_model = state["selected_model"]
|
| 37 |
+
|
| 38 |
+
logging.info(f"[QUERY] Processando: {user_input[:50]}...")
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
# Verifica se é saudação
|
| 42 |
+
if is_greeting(user_input):
|
| 43 |
+
greeting_response = "Olá! Estou aqui para ajudar com suas consultas. Pergunte algo relacionado aos dados carregados no agente!"
|
| 44 |
+
state.update({
|
| 45 |
+
"response": greeting_response,
|
| 46 |
+
"execution_time": time.time() - start_time,
|
| 47 |
+
"error": None
|
| 48 |
+
})
|
| 49 |
+
return state
|
| 50 |
+
|
| 51 |
+
# Recupera objetos necessários
|
| 52 |
+
obj_manager = get_object_manager()
|
| 53 |
+
|
| 54 |
+
# Recupera cache manager
|
| 55 |
+
cache_id = state.get("cache_id")
|
| 56 |
+
cache_manager = obj_manager.get_cache_manager(cache_id) if cache_id else None
|
| 57 |
+
|
| 58 |
+
# Verifica cache se disponível
|
| 59 |
+
if cache_manager:
|
| 60 |
+
cached_response = cache_manager.get_cached_response(user_input)
|
| 61 |
+
if cached_response:
|
| 62 |
+
logging.info(f"[CACHE] Retornando resposta do cache")
|
| 63 |
+
state.update({
|
| 64 |
+
"response": cached_response,
|
| 65 |
+
"execution_time": time.time() - start_time,
|
| 66 |
+
"error": None
|
| 67 |
+
})
|
| 68 |
+
return state
|
| 69 |
+
|
| 70 |
+
# Converte amostra do banco para DataFrame
|
| 71 |
+
db_sample_dict = state.get("db_sample_dict", {})
|
| 72 |
+
if not db_sample_dict:
|
| 73 |
+
raise ValueError("Amostra do banco não disponível")
|
| 74 |
+
|
| 75 |
+
# Reconstrói DataFrame da amostra
|
| 76 |
+
db_sample = pd.DataFrame(db_sample_dict.get("data", []))
|
| 77 |
+
if db_sample.empty:
|
| 78 |
+
raise ValueError("Dados de amostra vazios")
|
| 79 |
+
|
| 80 |
+
# Detecta tipo de query e prepara contexto
|
| 81 |
+
query_type = detect_query_type(user_input)
|
| 82 |
+
state["query_type"] = query_type
|
| 83 |
+
|
| 84 |
+
if query_type in ['sql_query', 'sql_query_graphic']:
|
| 85 |
+
# Obtém sugestão de query e observações do Processing Agent (se disponível)
|
| 86 |
+
suggested_query = state.get("suggested_query", "")
|
| 87 |
+
query_observations = state.get("query_observations", "")
|
| 88 |
+
|
| 89 |
+
# Prepara contexto para envio direto ao agentSQL
|
| 90 |
+
sql_context = prepare_sql_context(user_input, db_sample, suggested_query, query_observations)
|
| 91 |
+
state["sql_context"] = sql_context
|
| 92 |
+
|
| 93 |
+
logging.info(f"[DEBUG] Tipo de query detectado: {query_type}")
|
| 94 |
+
if suggested_query:
|
| 95 |
+
logging.info(f"[DEBUG] Query sugerida pelo Processing Agent incluída no contexto")
|
| 96 |
+
logging.info(f"[DEBUG] Contexto preparado para agentSQL")
|
| 97 |
+
else:
|
| 98 |
+
# Para tipos futuros (prediction)
|
| 99 |
+
error_msg = f"Tipo de query '{query_type}' ainda não implementado."
|
| 100 |
+
state.update({
|
| 101 |
+
"error": error_msg,
|
| 102 |
+
"response": error_msg,
|
| 103 |
+
"execution_time": time.time() - start_time
|
| 104 |
+
})
|
| 105 |
+
return state
|
| 106 |
+
|
| 107 |
+
# Recupera agente SQL
|
| 108 |
+
agent_id = state.get("agent_id")
|
| 109 |
+
if not agent_id:
|
| 110 |
+
raise ValueError("ID do agente SQL não encontrado")
|
| 111 |
+
|
| 112 |
+
sql_agent = obj_manager.get_sql_agent(agent_id)
|
| 113 |
+
if not sql_agent:
|
| 114 |
+
raise ValueError("Agente SQL não encontrado")
|
| 115 |
+
|
| 116 |
+
# Executa query no agente SQL com contexto direto
|
| 117 |
+
sql_result = await sql_agent.execute_query(state["sql_context"])
|
| 118 |
+
|
| 119 |
+
# Log da resposta do agente SQL
|
| 120 |
+
logging.info(f"[AGENT SQL] ===== RESPOSTA DO AGENTE SQL =====")
|
| 121 |
+
logging.info(f"[AGENT SQL] Sucesso: {sql_result['success']}")
|
| 122 |
+
logging.info(f"[AGENT SQL] Resposta completa:")
|
| 123 |
+
logging.info(f"{sql_result.get('output', 'Sem resposta')}")
|
| 124 |
+
if sql_result.get("sql_query"):
|
| 125 |
+
logging.info(f"[AGENT SQL] Query SQL capturada: {sql_result['sql_query']}")
|
| 126 |
+
logging.info(f"[AGENT SQL] ===== FIM DA RESPOSTA =====")
|
| 127 |
+
|
| 128 |
+
if not sql_result["success"]:
|
| 129 |
+
state.update({
|
| 130 |
+
"error": sql_result["output"],
|
| 131 |
+
"response": sql_result["output"],
|
| 132 |
+
"sql_result": sql_result
|
| 133 |
+
})
|
| 134 |
+
else:
|
| 135 |
+
# Captura query SQL do resultado do agente
|
| 136 |
+
sql_query_captured = sql_result.get("sql_query")
|
| 137 |
+
|
| 138 |
+
state.update({
|
| 139 |
+
"response": sql_result["output"],
|
| 140 |
+
"intermediate_steps": sql_result["intermediate_steps"],
|
| 141 |
+
"sql_result": sql_result,
|
| 142 |
+
"sql_query_extracted": sql_query_captured, # ← Query SQL capturada
|
| 143 |
+
"error": None
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
# Log apenas se não foi capturada (caso de erro)
|
| 147 |
+
if not sql_query_captured:
|
| 148 |
+
logging.warning("[QUERY] ⚠️ Nenhuma query SQL foi capturada pelo handler")
|
| 149 |
+
|
| 150 |
+
# Armazena no cache se disponível
|
| 151 |
+
if cache_manager and sql_result["success"]:
|
| 152 |
+
cache_manager.cache_response(user_input, state["response"])
|
| 153 |
+
|
| 154 |
+
state["execution_time"] = time.time() - start_time
|
| 155 |
+
logging.info(f"[QUERY] Concluído em {state['execution_time']:.2f}s")
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
error_msg = f"Erro ao processar query: {e}"
|
| 159 |
+
logging.error(f"[QUERY] {error_msg}")
|
| 160 |
+
state.update({
|
| 161 |
+
"error": error_msg,
|
| 162 |
+
"response": error_msg,
|
| 163 |
+
"execution_time": time.time() - start_time
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
return state
|
| 167 |
+
|
| 168 |
+
async def validate_query_input_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 169 |
+
"""
|
| 170 |
+
Nó para validar entrada da consulta
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
state: Estado com entrada do usuário
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Estado atualizado com validação
|
| 177 |
+
"""
|
| 178 |
+
user_input = state.get("user_input", "").strip()
|
| 179 |
+
|
| 180 |
+
if not user_input:
|
| 181 |
+
state.update({
|
| 182 |
+
"error": "Entrada vazia",
|
| 183 |
+
"response": "Por favor, digite uma pergunta.",
|
| 184 |
+
"execution_time": 0.0
|
| 185 |
+
})
|
| 186 |
+
return state
|
| 187 |
+
|
| 188 |
+
if len(user_input) > 1000:
|
| 189 |
+
state.update({
|
| 190 |
+
"error": "Entrada muito longa",
|
| 191 |
+
"response": "Pergunta muito longa. Por favor, seja mais conciso.",
|
| 192 |
+
"execution_time": 0.0
|
| 193 |
+
})
|
| 194 |
+
return state
|
| 195 |
+
|
| 196 |
+
# Validação passou
|
| 197 |
+
state["error"] = None
|
| 198 |
+
logging.info(f"[VALIDATION] Entrada validada: {len(user_input)} caracteres")
|
| 199 |
+
|
| 200 |
+
return state
|
| 201 |
+
|
| 202 |
+
async def prepare_query_context_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 203 |
+
"""
|
| 204 |
+
Nó para preparar contexto da consulta
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
state: Estado atual
|
| 208 |
+
|
| 209 |
+
Returns:
|
| 210 |
+
Estado com contexto preparado
|
| 211 |
+
"""
|
| 212 |
+
try:
|
| 213 |
+
# Verifica se todos os componentes necessários estão disponíveis
|
| 214 |
+
required_ids = ["agent_id", "engine_id", "cache_id"]
|
| 215 |
+
missing_ids = [id_name for id_name in required_ids if not state.get(id_name)]
|
| 216 |
+
|
| 217 |
+
if missing_ids:
|
| 218 |
+
raise ValueError(f"IDs necessários não encontrados: {missing_ids}")
|
| 219 |
+
|
| 220 |
+
obj_manager = get_object_manager()
|
| 221 |
+
|
| 222 |
+
# Verifica se objetos existem
|
| 223 |
+
for id_name in required_ids:
|
| 224 |
+
obj_id = state[id_name]
|
| 225 |
+
if id_name == "agent_id":
|
| 226 |
+
obj = obj_manager.get_sql_agent(obj_id)
|
| 227 |
+
elif id_name == "engine_id":
|
| 228 |
+
obj = obj_manager.get_engine(obj_id)
|
| 229 |
+
elif id_name == "cache_id":
|
| 230 |
+
obj = obj_manager.get_cache_manager(obj_id)
|
| 231 |
+
|
| 232 |
+
if obj is None:
|
| 233 |
+
raise ValueError(f"Objeto não encontrado para {id_name}: {obj_id}")
|
| 234 |
+
|
| 235 |
+
# Contexto preparado com sucesso
|
| 236 |
+
state["context_ready"] = True
|
| 237 |
+
logging.info("[CONTEXT] Contexto da consulta preparado")
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
error_msg = f"Erro ao preparar contexto: {e}"
|
| 241 |
+
logging.error(f"[CONTEXT] {error_msg}")
|
| 242 |
+
state.update({
|
| 243 |
+
"error": error_msg,
|
| 244 |
+
"context_ready": False
|
| 245 |
+
})
|
| 246 |
+
|
| 247 |
+
return state
|
nodes/refinement_node.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nó para refinamento de respostas
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
|
| 7 |
+
from agents.tools import refine_response_with_llm
|
| 8 |
+
|
| 9 |
+
async def refine_response_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 10 |
+
"""
|
| 11 |
+
Nó para refinar a resposta usando LLM adicional
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
state: Estado atual do agente
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
Estado atualizado com resposta refinada
|
| 18 |
+
"""
|
| 19 |
+
if not state.get("advanced_mode", False) or state.get("error"):
|
| 20 |
+
# Pula refinamento se modo avançado desabilitado ou há erro
|
| 21 |
+
logging.info("[REFINE] Pulando refinamento - modo avançado desabilitado ou erro presente")
|
| 22 |
+
return state
|
| 23 |
+
|
| 24 |
+
logging.info("[REFINE] Iniciando refinamento da resposta")
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
original_response = state.get("response", "")
|
| 28 |
+
user_input = state.get("user_input", "")
|
| 29 |
+
|
| 30 |
+
if not original_response or not user_input:
|
| 31 |
+
logging.warning("[REFINE] Resposta ou entrada do usuário não disponível")
|
| 32 |
+
return state
|
| 33 |
+
|
| 34 |
+
# Refina resposta com LLM adicional
|
| 35 |
+
refined_response = await refine_response_with_llm(
|
| 36 |
+
user_input,
|
| 37 |
+
original_response
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Atualiza estado com resposta refinada
|
| 41 |
+
state["response"] = refined_response
|
| 42 |
+
state["refined"] = True
|
| 43 |
+
|
| 44 |
+
logging.info("[REFINE] Resposta refinada com sucesso")
|
| 45 |
+
|
| 46 |
+
except Exception as e:
|
| 47 |
+
error_msg = f"Erro ao refinar resposta: {e}"
|
| 48 |
+
logging.error(f"[REFINE] {error_msg}")
|
| 49 |
+
# Mantém resposta original em caso de erro
|
| 50 |
+
state["refinement_error"] = error_msg
|
| 51 |
+
|
| 52 |
+
return state
|
| 53 |
+
|
| 54 |
+
async def check_refinement_quality_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 55 |
+
"""
|
| 56 |
+
Nó para verificar qualidade do refinamento
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
state: Estado com resposta refinada
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Estado com avaliação da qualidade
|
| 63 |
+
"""
|
| 64 |
+
try:
|
| 65 |
+
original_response = state.get("sql_result", {}).get("output", "")
|
| 66 |
+
refined_response = state.get("response", "")
|
| 67 |
+
|
| 68 |
+
if not state.get("refined", False):
|
| 69 |
+
state["refinement_quality"] = "not_refined"
|
| 70 |
+
return state
|
| 71 |
+
|
| 72 |
+
# Métricas simples de qualidade
|
| 73 |
+
quality_metrics = {
|
| 74 |
+
"length_increase": len(refined_response) - len(original_response),
|
| 75 |
+
"has_insights": any(word in refined_response.lower() for word in [
|
| 76 |
+
"insight", "análise", "interpretação", "conclusão", "tendência"
|
| 77 |
+
]),
|
| 78 |
+
"has_statistics": any(word in refined_response.lower() for word in [
|
| 79 |
+
"média", "total", "percentual", "proporção", "estatística"
|
| 80 |
+
]),
|
| 81 |
+
"improved": len(refined_response) > len(original_response) * 1.1
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# Determina qualidade geral
|
| 85 |
+
if quality_metrics["improved"] and (quality_metrics["has_insights"] or quality_metrics["has_statistics"]):
|
| 86 |
+
quality_score = "high"
|
| 87 |
+
elif quality_metrics["length_increase"] > 0:
|
| 88 |
+
quality_score = "medium"
|
| 89 |
+
else:
|
| 90 |
+
quality_score = "low"
|
| 91 |
+
|
| 92 |
+
state["refinement_quality"] = quality_score
|
| 93 |
+
state["quality_metrics"] = quality_metrics
|
| 94 |
+
|
| 95 |
+
logging.info(f"[REFINE] Qualidade avaliada: {quality_score}")
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logging.error(f"[REFINE] Erro ao avaliar qualidade: {e}")
|
| 99 |
+
state["refinement_quality"] = "error"
|
| 100 |
+
|
| 101 |
+
return state
|
| 102 |
+
|
| 103 |
+
async def format_final_response_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 104 |
+
"""
|
| 105 |
+
Nó para formatação final da resposta
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
state: Estado com resposta processada
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Estado com resposta formatada
|
| 112 |
+
"""
|
| 113 |
+
try:
|
| 114 |
+
response = state.get("response", "")
|
| 115 |
+
execution_time = state.get("execution_time", 0.0)
|
| 116 |
+
advanced_mode = state.get("advanced_mode", False)
|
| 117 |
+
refined = state.get("refined", False)
|
| 118 |
+
|
| 119 |
+
# Adiciona informações de contexto se necessário
|
| 120 |
+
if advanced_mode and refined:
|
| 121 |
+
quality = state.get("refinement_quality", "unknown")
|
| 122 |
+
if quality == "high":
|
| 123 |
+
response += "\n\n💡 *Resposta aprimorada com análise avançada*"
|
| 124 |
+
elif quality == "medium":
|
| 125 |
+
response += "\n\n🔍 *Resposta complementada*"
|
| 126 |
+
|
| 127 |
+
# Adiciona tempo de execução se significativo
|
| 128 |
+
if execution_time > 2.0:
|
| 129 |
+
response += f"\n\n⏱️ *Processado em {execution_time:.1f}s*"
|
| 130 |
+
|
| 131 |
+
# Formatação final
|
| 132 |
+
state["response"] = response.strip()
|
| 133 |
+
state["formatted"] = True
|
| 134 |
+
|
| 135 |
+
logging.info(f"[FORMAT] Resposta formatada - {len(response)} caracteres")
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logging.error(f"[FORMAT] Erro na formatação: {e}")
|
| 139 |
+
# Mantém resposta original se houver erro na formatação
|
| 140 |
+
|
| 141 |
+
return state
|
requirements.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core LangGraph and LangChain
|
| 2 |
+
langgraph>=0.2.0
|
| 3 |
+
langchain>=0.3.0
|
| 4 |
+
langchain-openai>=0.2.0
|
| 5 |
+
langchain-anthropic>=0.2.0
|
| 6 |
+
langchain-community>=0.3.0
|
| 7 |
+
langchain-core>=0.3.0
|
| 8 |
+
|
| 9 |
+
# LangSmith for observability and tracing
|
| 10 |
+
langsmith>=0.1.39
|
| 11 |
+
|
| 12 |
+
# Database and Data Processing
|
| 13 |
+
pandas>=2.0.0
|
| 14 |
+
sqlalchemy>=2.0.0
|
| 15 |
+
numpy>=1.24.0
|
| 16 |
+
|
| 17 |
+
# AI/ML Libraries
|
| 18 |
+
huggingface_hub>=0.20.0
|
| 19 |
+
|
| 20 |
+
# Graph Generation Libraries
|
| 21 |
+
matplotlib>=3.7.0
|
| 22 |
+
pillow>=10.0.0
|
| 23 |
+
|
| 24 |
+
# Web Interface
|
| 25 |
+
gradio>=4.0.0
|
| 26 |
+
|
| 27 |
+
# Utilities
|
| 28 |
+
python-dotenv>=1.0.0
|
tabela.csv
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:de0091898b8e75943617f2116e621dbbea7be1232659b93a3a8b23a3114f2ac3
|
| 3 |
-
size 162333121
|
|
|
|
|
|
|
|
|
|
|
|
utils/config.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configurações e constantes do projeto AgentGraph
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# Carrega variáveis de ambiente
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
# Configurações de API
|
| 12 |
+
HUGGINGFACE_API_KEY = os.getenv("HUGGINGFACE_API_KEY")
|
| 13 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 14 |
+
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
| 15 |
+
|
| 16 |
+
# Configurações do LangSmith (observabilidade)
|
| 17 |
+
LANGSMITH_API_KEY = os.getenv("LANGSMITH_API_KEY")
|
| 18 |
+
LANGSMITH_TRACING = os.getenv("LANGSMITH_TRACING", "false").lower() == "true"
|
| 19 |
+
LANGSMITH_ENDPOINT = os.getenv("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
| 20 |
+
LANGSMITH_PROJECT = os.getenv("LANGSMITH_PROJECT", "agentgraph-project")
|
| 21 |
+
|
| 22 |
+
# Configurações de arquivos e diretórios
|
| 23 |
+
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploaded_data")
|
| 24 |
+
DEFAULT_CSV_PATH = os.getenv("DEFAULT_CSV_PATH", "tabela.csv")
|
| 25 |
+
SQL_DB_PATH = os.getenv("SQL_DB_PATH", "data.db")
|
| 26 |
+
UPLOADED_CSV_PATH = os.path.join(UPLOAD_DIR, "tabela.csv")
|
| 27 |
+
|
| 28 |
+
# Modelos disponíveis para seleção (usados no agentSQL)
|
| 29 |
+
AVAILABLE_MODELS = {
|
| 30 |
+
"GPT-o3-mini": "o3-mini",
|
| 31 |
+
"GPT-4o-mini": "gpt-4o-mini",
|
| 32 |
+
"GPT-4o": "gpt-4o",
|
| 33 |
+
"Claude-3.5-Sonnet": "claude-3-5-sonnet-20241022"
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# Modelos para refinamento (apenas uso interno)
|
| 37 |
+
REFINEMENT_MODELS = {
|
| 38 |
+
"LLaMA 70B": "meta-llama/Llama-3.3-70B-Instruct",
|
| 39 |
+
"LlaMA 8B": "meta-llama/Llama-3.1-8B-Instruct",
|
| 40 |
+
"DeepSeek-R1": "deepseek-ai/DeepSeek-R1-0528"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# Mapeamento completo de modelos (para compatibilidade)
|
| 44 |
+
LLAMA_MODELS = {**AVAILABLE_MODELS, **REFINEMENT_MODELS}
|
| 45 |
+
|
| 46 |
+
MAX_TOKENS_MAP = {
|
| 47 |
+
# Modelos de refinamento
|
| 48 |
+
"meta-llama/Llama-3.3-70B-Instruct": 900,
|
| 49 |
+
"meta-llama/Llama-3.1-8B-Instruct": 700,
|
| 50 |
+
"deepseek-ai/DeepSeek-R1-0528": 8192,
|
| 51 |
+
# Modelos do agentSQL
|
| 52 |
+
"o3-mini": 4096,
|
| 53 |
+
"gpt-4o-mini": 4096,
|
| 54 |
+
"gpt-4o": 4096,
|
| 55 |
+
"claude-3-5-sonnet-20241022": 1024
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Modelos que usam OpenAI (GPT)
|
| 59 |
+
OPENAI_MODELS = {
|
| 60 |
+
"o3-mini",
|
| 61 |
+
"gpt-4o-mini",
|
| 62 |
+
"gpt-4o",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# Modelos que usam Anthropic (Claude)
|
| 66 |
+
ANTHROPIC_MODELS = {
|
| 67 |
+
"claude-3-5-sonnet-20241022"
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# Modelos que usam HuggingFace (para refinamento)
|
| 71 |
+
HUGGINGFACE_MODELS = {
|
| 72 |
+
"meta-llama/Llama-3.3-70B-Instruct",
|
| 73 |
+
"meta-llama/Llama-3.1-8B-Instruct",
|
| 74 |
+
"deepseek-ai/DeepSeek-R1-0528"
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
# Configurações do agente
|
| 78 |
+
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "GPT-4o-mini")
|
| 79 |
+
MAX_ITERATIONS = int(os.getenv("MAX_ITERATIONS", "40"))
|
| 80 |
+
TEMPERATURE = float(os.getenv("TEMPERATURE", "0"))
|
| 81 |
+
|
| 82 |
+
# Configurações do Gradio
|
| 83 |
+
GRADIO_SHARE = os.getenv("GRADIO_SHARE", "False").lower() == "true"
|
| 84 |
+
GRADIO_PORT = int(os.getenv("GRADIO_PORT", "7860"))
|
| 85 |
+
|
| 86 |
+
# Configurações de logging
|
| 87 |
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
| 88 |
+
|
| 89 |
+
# Configuração do logging
|
| 90 |
+
logging.basicConfig(
|
| 91 |
+
level=getattr(logging, LOG_LEVEL.upper()),
|
| 92 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Cria diretório de upload se não existir
|
| 96 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 97 |
+
|
| 98 |
+
# Configuração das variáveis de ambiente para OpenAI
|
| 99 |
+
if OPENAI_API_KEY:
|
| 100 |
+
os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY
|
| 101 |
+
|
| 102 |
+
# Configuração das variáveis de ambiente para Anthropic
|
| 103 |
+
if ANTHROPIC_API_KEY:
|
| 104 |
+
os.environ["ANTHROPIC_API_KEY"] = ANTHROPIC_API_KEY
|
| 105 |
+
|
| 106 |
+
# Configuração das variáveis de ambiente para LangSmith
|
| 107 |
+
if LANGSMITH_API_KEY:
|
| 108 |
+
os.environ["LANGSMITH_API_KEY"] = LANGSMITH_API_KEY
|
| 109 |
+
os.environ["LANGSMITH_TRACING"] = str(LANGSMITH_TRACING).lower()
|
| 110 |
+
os.environ["LANGSMITH_ENDPOINT"] = LANGSMITH_ENDPOINT
|
| 111 |
+
os.environ["LANGSMITH_PROJECT"] = LANGSMITH_PROJECT
|
| 112 |
+
logging.info(f"LangSmith configurado: projeto='{LANGSMITH_PROJECT}', tracing={LANGSMITH_TRACING}")
|
| 113 |
+
else:
|
| 114 |
+
logging.info("LangSmith não configurado (LANGSMITH_API_KEY não encontrada)")
|
| 115 |
+
|
| 116 |
+
def get_active_csv_path():
|
| 117 |
+
"""Retorna o CSV ativo: o carregado ou o padrão."""
|
| 118 |
+
if os.path.exists(UPLOADED_CSV_PATH):
|
| 119 |
+
logging.info(f"[CSV] Usando arquivo CSV carregado: {UPLOADED_CSV_PATH}")
|
| 120 |
+
return UPLOADED_CSV_PATH
|
| 121 |
+
else:
|
| 122 |
+
logging.info(f"[CSV] Usando arquivo CSV padrão: {DEFAULT_CSV_PATH}")
|
| 123 |
+
return DEFAULT_CSV_PATH
|
| 124 |
+
|
| 125 |
+
def validate_config():
|
| 126 |
+
"""Valida se as configurações necessárias estão presentes."""
|
| 127 |
+
errors = []
|
| 128 |
+
warnings = []
|
| 129 |
+
|
| 130 |
+
if not HUGGINGFACE_API_KEY:
|
| 131 |
+
errors.append("HUGGINGFACE_API_KEY não configurada")
|
| 132 |
+
|
| 133 |
+
if not OPENAI_API_KEY:
|
| 134 |
+
errors.append("OPENAI_API_KEY não configurada")
|
| 135 |
+
|
| 136 |
+
if not ANTHROPIC_API_KEY:
|
| 137 |
+
errors.append("ANTHROPIC_API_KEY não configurada")
|
| 138 |
+
|
| 139 |
+
if not os.path.exists(DEFAULT_CSV_PATH):
|
| 140 |
+
errors.append(f"Arquivo CSV padrão não encontrado: {DEFAULT_CSV_PATH}")
|
| 141 |
+
|
| 142 |
+
# LangSmith é opcional - apenas aviso se não configurado
|
| 143 |
+
if not LANGSMITH_API_KEY:
|
| 144 |
+
warnings.append("LANGSMITH_API_KEY não configurada - observabilidade desabilitada")
|
| 145 |
+
|
| 146 |
+
if errors:
|
| 147 |
+
raise ValueError(f"Erros de configuração: {', '.join(errors)}")
|
| 148 |
+
|
| 149 |
+
if warnings:
|
| 150 |
+
for warning in warnings:
|
| 151 |
+
logging.warning(warning)
|
| 152 |
+
|
| 153 |
+
logging.info("Configurações validadas com sucesso")
|
| 154 |
+
return True
|
| 155 |
+
|
| 156 |
+
def is_langsmith_enabled() -> bool:
|
| 157 |
+
"""
|
| 158 |
+
Verifica se o LangSmith está habilitado e configurado
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
True se LangSmith estiver habilitado, False caso contrário
|
| 162 |
+
"""
|
| 163 |
+
return bool(LANGSMITH_API_KEY and LANGSMITH_TRACING)
|
| 164 |
+
|
| 165 |
+
def get_langsmith_metadata() -> dict:
|
| 166 |
+
"""
|
| 167 |
+
Retorna metadados padrão para traces do LangSmith
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
Dicionário com metadados do projeto
|
| 171 |
+
"""
|
| 172 |
+
if not is_langsmith_enabled():
|
| 173 |
+
return {}
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"project": LANGSMITH_PROJECT,
|
| 177 |
+
"application": "AgentGraph",
|
| 178 |
+
"version": "1.0.0",
|
| 179 |
+
"environment": "production"
|
| 180 |
+
}
|
utils/database.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Funções para gerenciamento de banco de dados e processamento de CSV
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from sqlalchemy import create_engine
|
| 7 |
+
from sqlalchemy.types import DateTime, Integer, Float
|
| 8 |
+
from langchain_community.utilities import SQLDatabase
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from utils.config import SQL_DB_PATH
|
| 13 |
+
|
| 14 |
+
# FUNÇÃO REMOVIDA: create_engine_and_load_db
|
| 15 |
+
# Esta função foi substituída pela nova arquitetura de nós
|
| 16 |
+
# Use: csv_processing_node.py + database_node.py
|
| 17 |
+
|
| 18 |
+
def create_engine_from_processed_dataframe(processed_df: pd.DataFrame, sql_types: dict, sql_db_path: str = SQL_DB_PATH):
|
| 19 |
+
"""
|
| 20 |
+
Cria engine SQLAlchemy a partir de DataFrame já processado
|
| 21 |
+
NOVA VERSÃO - usa processamento genérico
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
processed_df: DataFrame já processado
|
| 25 |
+
sql_types: Dicionário com tipos SQL para as colunas
|
| 26 |
+
sql_db_path: Caminho para o banco SQLite
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
SQLAlchemy Engine
|
| 30 |
+
"""
|
| 31 |
+
logging.info("Criando banco de dados a partir de DataFrame processado...")
|
| 32 |
+
engine = create_engine(f"sqlite:///{sql_db_path}")
|
| 33 |
+
|
| 34 |
+
logging.info("[DEBUG] Tipos das colunas processadas:")
|
| 35 |
+
logging.info(processed_df.dtypes)
|
| 36 |
+
|
| 37 |
+
# Salva no banco SQLite
|
| 38 |
+
processed_df.to_sql("tabela", engine, index=False, if_exists="replace", dtype=sql_types)
|
| 39 |
+
logging.info(f"Banco de dados SQL criado com sucesso! {len(processed_df)} registros salvos")
|
| 40 |
+
return engine
|
| 41 |
+
|
| 42 |
+
def create_sql_database(engine) -> SQLDatabase:
|
| 43 |
+
"""
|
| 44 |
+
Cria objeto SQLDatabase do LangChain a partir de uma engine
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
engine: SQLAlchemy Engine
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
SQLDatabase do LangChain
|
| 51 |
+
"""
|
| 52 |
+
return SQLDatabase(engine=engine)
|
| 53 |
+
|
| 54 |
+
def get_sample_data(engine, limit: int = 10) -> pd.DataFrame:
|
| 55 |
+
"""
|
| 56 |
+
Obtém dados de amostra do banco para contexto
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
engine: SQLAlchemy Engine
|
| 60 |
+
limit: Número de linhas para retornar
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
DataFrame com dados de amostra
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
return pd.read_sql_query(f"SELECT * FROM tabela LIMIT {limit}", engine)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logging.error(f"Erro ao obter dados de amostra: {e}")
|
| 69 |
+
return pd.DataFrame()
|
| 70 |
+
|
| 71 |
+
def validate_database(engine) -> bool:
|
| 72 |
+
"""
|
| 73 |
+
Valida se o banco de dados está funcionando corretamente
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
engine: SQLAlchemy Engine
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
True se válido, False caso contrário
|
| 80 |
+
"""
|
| 81 |
+
try:
|
| 82 |
+
# Testa uma query simples
|
| 83 |
+
result = pd.read_sql_query("SELECT COUNT(*) as count FROM tabela", engine)
|
| 84 |
+
count = result.iloc[0]['count']
|
| 85 |
+
logging.info(f"Banco validado: {count} registros encontrados")
|
| 86 |
+
return count > 0
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logging.error(f"Erro na validação do banco: {e}")
|
| 89 |
+
return False
|
| 90 |
+
|
| 91 |
+
# FUNÇÃO REMOVIDA: async_create_engine_and_load_db
|
| 92 |
+
# Esta função foi removida junto com create_engine_and_load_db
|
| 93 |
+
# Use a nova arquitetura de nós: csv_processing_node.py + database_node.py
|
utils/object_manager.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gerenciador de objetos não-serializáveis para LangGraph
|
| 3 |
+
"""
|
| 4 |
+
import uuid
|
| 5 |
+
from typing import Dict, Any, Optional
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
class ObjectManager:
|
| 9 |
+
"""
|
| 10 |
+
Gerencia objetos não-serializáveis que não podem ser incluídos no estado do LangGraph
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self._objects: Dict[str, Any] = {}
|
| 15 |
+
self._sql_agents: Dict[str, Any] = {}
|
| 16 |
+
self._processing_agents: Dict[str, Any] = {}
|
| 17 |
+
self._engines: Dict[str, Any] = {}
|
| 18 |
+
self._databases: Dict[str, Any] = {}
|
| 19 |
+
self._cache_managers: Dict[str, Any] = {}
|
| 20 |
+
# Mapeamento para relacionar agentes com seus bancos
|
| 21 |
+
self._agent_db_mapping: Dict[str, str] = {}
|
| 22 |
+
|
| 23 |
+
def store_sql_agent(self, agent: Any, db_id: str = None) -> str:
|
| 24 |
+
"""Armazena agente SQL e retorna ID"""
|
| 25 |
+
agent_id = str(uuid.uuid4())
|
| 26 |
+
self._sql_agents[agent_id] = agent
|
| 27 |
+
|
| 28 |
+
# Mapeia agente com seu banco se fornecido
|
| 29 |
+
if db_id:
|
| 30 |
+
self._agent_db_mapping[agent_id] = db_id
|
| 31 |
+
|
| 32 |
+
logging.info(f"Agente SQL armazenado com ID: {agent_id}")
|
| 33 |
+
return agent_id
|
| 34 |
+
|
| 35 |
+
def get_sql_agent(self, agent_id: str) -> Optional[Any]:
|
| 36 |
+
"""Recupera agente SQL pelo ID"""
|
| 37 |
+
return self._sql_agents.get(agent_id)
|
| 38 |
+
|
| 39 |
+
def store_processing_agent(self, agent: Any) -> str:
|
| 40 |
+
"""Armazena Processing Agent e retorna ID"""
|
| 41 |
+
agent_id = str(uuid.uuid4())
|
| 42 |
+
self._processing_agents[agent_id] = agent
|
| 43 |
+
logging.info(f"Processing Agent armazenado com ID: {agent_id}")
|
| 44 |
+
return agent_id
|
| 45 |
+
|
| 46 |
+
def get_processing_agent(self, agent_id: str) -> Optional[Any]:
|
| 47 |
+
"""Recupera Processing Agent pelo ID"""
|
| 48 |
+
return self._processing_agents.get(agent_id)
|
| 49 |
+
|
| 50 |
+
def store_engine(self, engine: Any) -> str:
|
| 51 |
+
"""Armazena engine e retorna ID"""
|
| 52 |
+
engine_id = str(uuid.uuid4())
|
| 53 |
+
self._engines[engine_id] = engine
|
| 54 |
+
logging.info(f"Engine armazenada com ID: {engine_id}")
|
| 55 |
+
return engine_id
|
| 56 |
+
|
| 57 |
+
def get_engine(self, engine_id: str) -> Optional[Any]:
|
| 58 |
+
"""Recupera engine pelo ID"""
|
| 59 |
+
return self._engines.get(engine_id)
|
| 60 |
+
|
| 61 |
+
def store_database(self, database: Any) -> str:
|
| 62 |
+
"""Armazena banco de dados e retorna ID"""
|
| 63 |
+
db_id = str(uuid.uuid4())
|
| 64 |
+
self._databases[db_id] = database
|
| 65 |
+
logging.info(f"Banco de dados armazenado com ID: {db_id}")
|
| 66 |
+
return db_id
|
| 67 |
+
|
| 68 |
+
def get_database(self, db_id: str) -> Optional[Any]:
|
| 69 |
+
"""Recupera banco de dados pelo ID"""
|
| 70 |
+
return self._databases.get(db_id)
|
| 71 |
+
|
| 72 |
+
def get_db_id_for_agent(self, agent_id: str) -> Optional[str]:
|
| 73 |
+
"""Recupera ID do banco associado ao agente"""
|
| 74 |
+
return self._agent_db_mapping.get(agent_id)
|
| 75 |
+
|
| 76 |
+
def store_cache_manager(self, cache_manager: Any) -> str:
|
| 77 |
+
"""Armazena cache manager e retorna ID"""
|
| 78 |
+
cache_id = str(uuid.uuid4())
|
| 79 |
+
self._cache_managers[cache_id] = cache_manager
|
| 80 |
+
logging.info(f"Cache manager armazenado com ID: {cache_id}")
|
| 81 |
+
return cache_id
|
| 82 |
+
|
| 83 |
+
def get_cache_manager(self, cache_id: str) -> Optional[Any]:
|
| 84 |
+
"""Recupera cache manager pelo ID"""
|
| 85 |
+
return self._cache_managers.get(cache_id)
|
| 86 |
+
|
| 87 |
+
def store_object(self, obj: Any, category: str = "general") -> str:
|
| 88 |
+
"""Armazena objeto genérico e retorna ID"""
|
| 89 |
+
obj_id = str(uuid.uuid4())
|
| 90 |
+
self._objects[obj_id] = {"object": obj, "category": category}
|
| 91 |
+
logging.info(f"Objeto {category} armazenado com ID: {obj_id}")
|
| 92 |
+
return obj_id
|
| 93 |
+
|
| 94 |
+
def get_object(self, obj_id: str) -> Optional[Any]:
|
| 95 |
+
"""Recupera objeto pelo ID"""
|
| 96 |
+
obj_data = self._objects.get(obj_id)
|
| 97 |
+
return obj_data["object"] if obj_data else None
|
| 98 |
+
|
| 99 |
+
def update_sql_agent(self, agent_id: str, new_agent: Any) -> bool:
|
| 100 |
+
"""Atualiza agente SQL existente"""
|
| 101 |
+
if agent_id in self._sql_agents:
|
| 102 |
+
self._sql_agents[agent_id] = new_agent
|
| 103 |
+
logging.info(f"Agente SQL atualizado: {agent_id}")
|
| 104 |
+
return True
|
| 105 |
+
return False
|
| 106 |
+
|
| 107 |
+
def update_engine(self, engine_id: str, new_engine: Any) -> bool:
|
| 108 |
+
"""Atualiza engine existente"""
|
| 109 |
+
if engine_id in self._engines:
|
| 110 |
+
self._engines[engine_id] = new_engine
|
| 111 |
+
logging.info(f"Engine atualizada: {engine_id}")
|
| 112 |
+
return True
|
| 113 |
+
return False
|
| 114 |
+
|
| 115 |
+
def update_cache_manager(self, cache_id: str, new_cache_manager: Any) -> bool:
|
| 116 |
+
"""Atualiza cache manager existente"""
|
| 117 |
+
if cache_id in self._cache_managers:
|
| 118 |
+
self._cache_managers[cache_id] = new_cache_manager
|
| 119 |
+
logging.info(f"Cache manager atualizado: {cache_id}")
|
| 120 |
+
return True
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
def clear_all(self):
|
| 124 |
+
"""Limpa todos os objetos armazenados"""
|
| 125 |
+
self._objects.clear()
|
| 126 |
+
self._sql_agents.clear()
|
| 127 |
+
self._engines.clear()
|
| 128 |
+
self._databases.clear()
|
| 129 |
+
self._cache_managers.clear()
|
| 130 |
+
self._agent_db_mapping.clear()
|
| 131 |
+
logging.info("Todos os objetos foram limpos do gerenciador")
|
| 132 |
+
|
| 133 |
+
def get_stats(self) -> Dict[str, int]:
|
| 134 |
+
"""Retorna estatísticas dos objetos armazenados"""
|
| 135 |
+
return {
|
| 136 |
+
"sql_agents": len(self._sql_agents),
|
| 137 |
+
"engines": len(self._engines),
|
| 138 |
+
"databases": len(self._databases),
|
| 139 |
+
"cache_managers": len(self._cache_managers),
|
| 140 |
+
"general_objects": len(self._objects),
|
| 141 |
+
"agent_db_mappings": len(self._agent_db_mapping)
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
# Instância global do gerenciador
|
| 145 |
+
_object_manager: Optional[ObjectManager] = None
|
| 146 |
+
|
| 147 |
+
def get_object_manager() -> ObjectManager:
|
| 148 |
+
"""Retorna instância singleton do gerenciador de objetos"""
|
| 149 |
+
global _object_manager
|
| 150 |
+
if _object_manager is None:
|
| 151 |
+
_object_manager = ObjectManager()
|
| 152 |
+
return _object_manager
|
| 153 |
+
|
| 154 |
+
def reset_object_manager():
|
| 155 |
+
"""Reseta o gerenciador de objetos"""
|
| 156 |
+
global _object_manager
|
| 157 |
+
if _object_manager:
|
| 158 |
+
_object_manager.clear_all()
|
| 159 |
+
_object_manager = ObjectManager()
|