rwayz commited on
Commit
88b683e
·
1 Parent(s): 780850e
.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()