rwayz commited on
Commit
6b29104
·
1 Parent(s): ce36b49
.gitignore ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Arquivos de upload (PDFs e outros documentos)
2
+ uploaded_data/
3
+ temp/
4
+
5
+ # Cache Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+ *.so
10
+
11
+ # Ambientes virtuais
12
+ .env
13
+ .venv
14
+ env/
15
+ venv/
16
+ ENV/
17
+ env.bak/
18
+ venv.bak/
19
+
20
+ # IDEs
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+ *.swo
25
+
26
+ # Logs
27
+ *.log
28
+ logs/
29
+
30
+ # Arquivos temporários do sistema
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # Arquivos de configuração local
35
+ config.local.py
36
+ settings.local.py
37
+
38
+ # Arquivos de backup
39
+ *.bak
40
+ *.backup
41
+ *~
42
+
43
+ # Arquivos de teste
44
+ test_files/
45
+ *.test
46
+
47
+ # Arquivos grandes ou binários
48
+ *.pdf
49
+ *.docx
50
+ *.xlsx
51
+ *.pptx
52
+ *.zip
53
+ *.tar.gz
54
+ *.rar
55
+
56
+ # Modelos salvos localmente
57
+ models/
58
+ checkpoints/
agents/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Módulo de agentes do AgentPDF
agents/state.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Definições de estado para o AgentPDF usando LangGraph.
3
+
4
+ Este módulo define as estruturas de estado que serão utilizadas
5
+ pelos nós do grafo para compartilhar informações durante a execução.
6
+ """
7
+
8
+ from typing import List, Optional, Dict, Any
9
+ from typing_extensions import TypedDict
10
+ from langchain_core.messages import BaseMessage
11
+ from langgraph.graph.message import add_messages
12
+ from typing import Annotated
13
+
14
+
15
+ class PDFState(TypedDict):
16
+ """
17
+ Estado principal do AgentPDF.
18
+
19
+ Contém todas as informações necessárias para o processamento
20
+ de PDFs e geração de respostas.
21
+ """
22
+ # Mensagens da conversa
23
+ messages: Annotated[List[BaseMessage], add_messages]
24
+
25
+ # Informações do PDF
26
+ pdf_path: Optional[str]
27
+ pdf_text: Optional[str]
28
+ pdf_chunks: Optional[List[str]]
29
+
30
+ # Vector store e embeddings
31
+ vector_store: Optional[Any]
32
+ embeddings_created: bool
33
+
34
+ # Contexto recuperado
35
+ retrieved_context: Optional[List[str]]
36
+
37
+ # Pergunta do usuário
38
+ user_question: Optional[str]
39
+
40
+ # Resposta final
41
+ final_answer: Optional[str]
42
+
43
+ # Status do processamento
44
+ processing_status: str
45
+ error_message: Optional[str]
46
+
47
+
48
+ class ProcessingState(TypedDict):
49
+ """
50
+ Estado específico para processamento de documentos.
51
+ """
52
+ document_path: str
53
+ extracted_text: Optional[str]
54
+ text_chunks: Optional[List[str]]
55
+ chunk_size: int
56
+ chunk_overlap: int
57
+
58
+
59
+ class RetrievalState(TypedDict):
60
+ """
61
+ Estado específico para recuperação de contexto.
62
+ """
63
+ query: str
64
+ retrieved_docs: Optional[List[str]]
65
+ similarity_scores: Optional[List[float]]
66
+ top_k: int
67
+
68
+
69
+ class LLMState(TypedDict):
70
+ """
71
+ Estado específico para interação com o LLM.
72
+ """
73
+ system_prompt: str
74
+ user_query: str
75
+ context: Optional[str]
76
+ response: Optional[str]
77
+ model_name: str
78
+
79
+
80
+ # Estados de entrada e saída para diferentes nós
81
+ class InputState(TypedDict):
82
+ """Estado de entrada para o grafo."""
83
+ messages: Annotated[List[BaseMessage], add_messages]
84
+ pdf_path: Optional[str]
85
+
86
+
87
+ class OutputState(TypedDict):
88
+ """Estado de saída do grafo."""
89
+ messages: Annotated[List[BaseMessage], add_messages]
90
+ final_answer: str
91
+
92
+
93
+ # Configurações padrão
94
+ DEFAULT_CHUNK_SIZE = 1000
95
+ DEFAULT_CHUNK_OVERLAP = 200
96
+ DEFAULT_TOP_K = 5
97
+ DEFAULT_MODEL = "gpt-4o-mini"
98
+
99
+ # Status de processamento
100
+ class ProcessingStatus:
101
+ IDLE = "idle"
102
+ LOADING_PDF = "loading_pdf"
103
+ PROCESSING_TEXT = "processing_text"
104
+ CREATING_EMBEDDINGS = "creating_embeddings"
105
+ RETRIEVING_CONTEXT = "retrieving_context"
106
+ GENERATING_RESPONSE = "generating_response"
107
+ COMPLETED = "completed"
108
+ ERROR = "error"
app.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aplicação principal do AgentPDF.
3
+
4
+ Este é o ponto de entrada da aplicação que inicializa
5
+ a interface Gradio e configura o ambiente.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import warnings
11
+ from pathlib import Path
12
+
13
+ # Adiciona o diretório raiz ao path
14
+ root_dir = Path(__file__).parent
15
+ sys.path.insert(0, str(root_dir))
16
+
17
+ # Suprime warnings desnecessários
18
+ warnings.filterwarnings("ignore", category=UserWarning)
19
+ warnings.filterwarnings("ignore", category=FutureWarning)
20
+
21
+ from interface.modern_interface import create_modern_gradio_app
22
+ from utils.config import Config
23
+ from utils.logger import main_logger, setup_logger
24
+
25
+
26
+ def setup_environment():
27
+ """Configura o ambiente da aplicação."""
28
+ # Configura logging
29
+ setup_logger("AgentPDF", "INFO")
30
+
31
+ # Verifica configurações
32
+ if not Config.validate_config():
33
+ main_logger.warning("⚠️ Configuração incompleta detectada!")
34
+ main_logger.warning(" Certifique-se de configurar OPENAI_API_KEY no arquivo .env")
35
+ main_logger.warning(" A aplicação pode não funcionar corretamente sem a chave da API.")
36
+
37
+ # Cria diretórios necessários
38
+ os.makedirs(Config.UPLOAD_DIR, exist_ok=True)
39
+ os.makedirs(Config.TEMP_DIR, exist_ok=True)
40
+
41
+ main_logger.info("🚀 Ambiente configurado com sucesso!")
42
+
43
+
44
+ def main():
45
+ """Função principal da aplicação."""
46
+ try:
47
+ # Banner de inicialização
48
+ print("""
49
+ ╔══════════════════════════════════════════════════════════════╗
50
+ ║ 🤖 AgentPDF ║
51
+ ║ ║
52
+ ║ Chat Inteligente com Documentos PDF ║
53
+ ║ ║
54
+ ║ Powered by LangChain + LangGraph + GPT-4o-mini ║
55
+ ╚══════════════════════════════════════════════════════════════╝
56
+ """)
57
+
58
+ # Configura ambiente
59
+ setup_environment()
60
+
61
+ # Informações de inicialização
62
+ main_logger.info("🔧 Inicializando AgentPDF...")
63
+ main_logger.info(f"📁 Diretório de upload: {Config.UPLOAD_DIR}")
64
+ main_logger.info(f"🌐 Porta: {Config.GRADIO_PORT}")
65
+ main_logger.info(f"🔑 OpenAI API configurada: {'✅' if Config.OPENAI_API_KEY else '❌'}")
66
+
67
+ # Cria e executa a aplicação Gradio
68
+ main_logger.info("🎨 Criando interface Gradio moderna...")
69
+ app = create_modern_gradio_app()
70
+
71
+ main_logger.info("🚀 Iniciando servidor...")
72
+ main_logger.info(f"🌍 Acesse: http://localhost:{Config.GRADIO_PORT}")
73
+
74
+ # Executa a aplicação
75
+ app.launch(
76
+ server_name="0.0.0.0",
77
+ server_port=Config.GRADIO_PORT,
78
+ share=Config.GRADIO_SHARE,
79
+ show_error=True,
80
+ quiet=False
81
+ )
82
+
83
+ except KeyboardInterrupt:
84
+ main_logger.info("👋 Aplicação interrompida pelo usuário")
85
+ sys.exit(0)
86
+
87
+ except Exception as e:
88
+ main_logger.error(f"❌ Erro fatal na aplicação: {e}")
89
+ main_logger.exception("Detalhes do erro:")
90
+ sys.exit(1)
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
interface/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Módulo da interface Gradio
interface/modern_interface.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Interface Gradio moderna para o AgentPDF com tema escuro.
3
+
4
+ Esta interface replica o design moderno da imagem fornecida.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ import gradio as gr
10
+ from typing import List, Tuple, Optional
11
+
12
+ from main_graph import get_agent_graph, process_pdf_file, ask_pdf_question
13
+ from utils.config import Config
14
+ from utils.logger import main_logger
15
+
16
+
17
+ class ModernAgentPDFInterface:
18
+ """Interface moderna para o AgentPDF."""
19
+
20
+ def __init__(self):
21
+ """Inicializa a interface."""
22
+ self.current_state = None
23
+ self.chat_history = []
24
+ self.pdf_processed = False
25
+ self.current_pdf_name = None
26
+
27
+ def upload_pdf(self, file) -> Tuple[str, str, List[List[str]]]:
28
+ """Processa o upload de um arquivo PDF."""
29
+ try:
30
+ if file is None:
31
+ return "❌ Erro: Nenhum arquivo selecionado", "Selecione um arquivo PDF", []
32
+
33
+ if not file.name.lower().endswith('.pdf'):
34
+ return "❌ Erro: Formato inválido", "Apenas arquivos PDF são aceitos", []
35
+
36
+ # Processa o arquivo
37
+ upload_dir = Config.UPLOAD_DIR
38
+ os.makedirs(upload_dir, exist_ok=True)
39
+
40
+ filename = f"uploaded_{os.path.basename(file.name)}"
41
+ pdf_path = os.path.join(upload_dir, filename)
42
+ shutil.copy2(file.name, pdf_path)
43
+
44
+ # Processa o PDF
45
+ result = process_pdf_file(pdf_path)
46
+
47
+ if result["success"]:
48
+ self.current_state = result["result"]
49
+ self.pdf_processed = True
50
+ self.current_pdf_name = os.path.basename(file.name)
51
+ self.chat_history = []
52
+
53
+ welcome_message = [
54
+ ["Sistema", f"✅ PDF '{self.current_pdf_name}' processado com sucesso!"]
55
+ ]
56
+
57
+ return (
58
+ "✅ Documento processado",
59
+ f"PDF: {self.current_pdf_name}\nStatus: Pronto para perguntas",
60
+ welcome_message
61
+ )
62
+ else:
63
+ self.pdf_processed = False
64
+ return "❌ Erro no processamento", f"Erro: {result['error']}", []
65
+
66
+ except Exception as e:
67
+ main_logger.error(f"Erro no upload: {e}")
68
+ return "❌ Erro inesperado", f"Erro: {str(e)}", []
69
+
70
+ def chat_with_pdf(self, message: str, history: List[List[str]]) -> Tuple[List[List[str]], str]:
71
+ """Processa uma mensagem do chat."""
72
+ try:
73
+ if not self.pdf_processed or not self.current_state:
74
+ error_msg = "❌ Faça upload de um PDF primeiro"
75
+ history.append([message, error_msg])
76
+ return history, ""
77
+
78
+ if not message.strip():
79
+ return history, ""
80
+
81
+ # Processa a pergunta
82
+ result = ask_pdf_question(message, self.current_state)
83
+
84
+ if result["success"]:
85
+ answer = result["answer"]
86
+ if result.get("result"):
87
+ self.current_state = result["result"]
88
+ else:
89
+ answer = f"❌ Erro: {result['error']}"
90
+
91
+ history.append([message, answer])
92
+ return history, ""
93
+
94
+ except Exception as e:
95
+ main_logger.error(f"Erro no chat: {e}")
96
+ error_msg = f"❌ Erro inesperado: {str(e)}"
97
+ history.append([message, error_msg])
98
+ return history, ""
99
+
100
+ def clear_chat(self) -> Tuple[List, str]:
101
+ """Limpa o histórico do chat."""
102
+ self.chat_history = []
103
+ return [], ""
104
+
105
+ def get_pdf_info(self) -> str:
106
+ """Retorna informações sobre o PDF atual."""
107
+ if not self.pdf_processed or not self.current_pdf_name:
108
+ return "Nenhum documento carregado"
109
+
110
+ info = f"📄 {self.current_pdf_name}\n"
111
+ info += f"✅ Processado e indexado\n"
112
+
113
+ if self.current_state:
114
+ chunks = self.current_state.get("pdf_chunks", [])
115
+ if chunks:
116
+ info += f"📊 {len(chunks)} seções"
117
+
118
+ return info
119
+
120
+ def create_interface(self) -> gr.Blocks:
121
+ """Cria a interface moderna usando templates nativos do Gradio."""
122
+
123
+ # CSS simples e limpo
124
+ css = """
125
+ .gradio-container {
126
+ max-width: 100% !important;
127
+ padding: 20px !important;
128
+ }
129
+
130
+ .chat-container {
131
+ height: 70vh !important;
132
+ }
133
+
134
+ .send-button {
135
+ height: 56px !important;
136
+ min-height: 56px !important;
137
+ }
138
+
139
+ .sidebar-config {
140
+ max-width: 320px !important;
141
+ min-width: 320px !important;
142
+ width: 320px !important;
143
+ }
144
+ """
145
+
146
+ with gr.Blocks(
147
+ title="AgentPDF",
148
+ theme=gr.themes.Soft(
149
+ primary_hue="blue",
150
+ secondary_hue="slate",
151
+ neutral_hue="slate"
152
+ ),
153
+ css=css
154
+ ) as interface:
155
+
156
+ gr.Markdown("# 🤖 AgentPDF - Chat com Documentos")
157
+
158
+ with gr.Row():
159
+ # SIDEBAR - Configurações
160
+ with gr.Column(scale=1, elem_classes=["sidebar-config"]):
161
+ gr.Markdown("## ⚙️ Configurações")
162
+
163
+ with gr.Group():
164
+ file_upload = gr.File(
165
+ label="Selecione um PDF",
166
+ file_types=[".pdf"],
167
+ type="filepath"
168
+ )
169
+
170
+ upload_btn = gr.Button(
171
+ "🚀 Processar PDF",
172
+ variant="primary",
173
+ size="lg"
174
+ )
175
+
176
+ with gr.Group():
177
+ upload_status = gr.Textbox(
178
+ label="📊 Status",
179
+ interactive=False,
180
+ placeholder="Aguardando upload...",
181
+ lines=1
182
+ )
183
+
184
+ pdf_info = gr.Textbox(
185
+ label="📄 Informações",
186
+ interactive=False,
187
+ value="Nenhum documento carregado",
188
+ lines=2
189
+ )
190
+
191
+
192
+ # ÁREA PRINCIPAL - Chat
193
+ with gr.Column(scale=3):
194
+ gr.Markdown("## 💬 Conversa")
195
+
196
+ chatbot = gr.Chatbot(
197
+ elem_classes=["chat-container"],
198
+ show_copy_button=True,
199
+ bubble_full_width=False
200
+ )
201
+
202
+ with gr.Row():
203
+ msg_input = gr.Textbox(
204
+ placeholder="Digite sua pergunta sobre o PDF...",
205
+ show_label=False,
206
+ scale=5,
207
+ lines=1
208
+ )
209
+
210
+ send_btn = gr.Button(
211
+ "📤",
212
+ variant="primary",
213
+ scale=1,
214
+ elem_classes=["send-button"]
215
+ )
216
+
217
+ # Eventos da interface
218
+ upload_btn.click(
219
+ fn=self.upload_pdf,
220
+ inputs=[file_upload],
221
+ outputs=[upload_status, pdf_info, chatbot],
222
+ show_progress=True
223
+ )
224
+
225
+ send_btn.click(
226
+ fn=self.chat_with_pdf,
227
+ inputs=[msg_input, chatbot],
228
+ outputs=[chatbot, msg_input],
229
+ show_progress=True
230
+ )
231
+
232
+ msg_input.submit(
233
+ fn=self.chat_with_pdf,
234
+ inputs=[msg_input, chatbot],
235
+ outputs=[chatbot, msg_input],
236
+ show_progress=True
237
+ )
238
+
239
+ return interface
240
+
241
+
242
+ def create_modern_gradio_app() -> gr.Blocks:
243
+ """Cria a aplicação Gradio moderna."""
244
+ interface = ModernAgentPDFInterface()
245
+ return interface.create_interface()
246
+
247
+
248
+ if __name__ == "__main__":
249
+ app = create_modern_gradio_app()
250
+ app.launch(
251
+ server_name="0.0.0.0",
252
+ server_port=Config.GRADIO_PORT,
253
+ share=Config.GRADIO_SHARE,
254
+ show_error=True
255
+ )
main_graph.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grafo principal do AgentPDF usando LangGraph.
3
+
4
+ Este módulo define o grafo principal que orquestra todos os nós
5
+ para processar PDFs e responder perguntas usando LLM.
6
+ """
7
+
8
+ from typing import Literal
9
+ from langgraph.graph import StateGraph, START, END
10
+ from langgraph.graph.message import add_messages
11
+ from langchain_core.messages import HumanMessage
12
+
13
+ from agents.state import PDFState, ProcessingStatus
14
+ from nodes.pdf_loader import load_pdf_node
15
+ from nodes.text_processor import text_processing_node
16
+ from nodes.embeddings_creator import embeddings_creation_node
17
+ from nodes.context_retriever import context_retrieval_node
18
+ from nodes.llm_agent import llm_agent_node
19
+ from utils.logger import log_graph_execution, main_logger
20
+ from utils.config import Config
21
+
22
+
23
+ class AgentPDFGraph:
24
+ """
25
+ Classe principal do grafo AgentPDF.
26
+
27
+ Gerencia o fluxo de processamento de PDFs e geração de respostas
28
+ usando a arquitetura de nós do LangGraph.
29
+ """
30
+
31
+ def __init__(self):
32
+ """Inicializa o grafo AgentPDF."""
33
+ self.graph = None
34
+ self._build_graph()
35
+ log_graph_execution("INIT", "Grafo AgentPDF inicializado")
36
+
37
+ def _build_graph(self):
38
+ """Constrói o grafo com todos os nós e conexões."""
39
+ # Cria o StateGraph
40
+ graph_builder = StateGraph(PDFState)
41
+
42
+ # Adiciona todos os nós
43
+ self._add_nodes(graph_builder)
44
+
45
+ # Define as conexões entre nós
46
+ self._add_edges(graph_builder)
47
+
48
+ # Compila o grafo
49
+ self.graph = graph_builder.compile()
50
+
51
+ log_graph_execution("BUILD", "Grafo construído e compilado com sucesso")
52
+
53
+ def _add_nodes(self, builder: StateGraph):
54
+ """
55
+ Adiciona todos os nós ao grafo.
56
+
57
+ Args:
58
+ builder: Builder do StateGraph
59
+ """
60
+ # Nó de carregamento de PDF
61
+ builder.add_node("load_pdf", load_pdf_node)
62
+
63
+ # Nó de processamento de texto
64
+ builder.add_node("process_text", text_processing_node)
65
+
66
+ # Nó de criação de embeddings
67
+ builder.add_node("create_embeddings", embeddings_creation_node)
68
+
69
+ # Nó de recuperação de contexto
70
+ builder.add_node("retrieve_context", context_retrieval_node)
71
+
72
+ # Nó do agente LLM
73
+ builder.add_node("llm_agent", llm_agent_node)
74
+
75
+ log_graph_execution("NODES", "Todos os nós adicionados ao grafo")
76
+
77
+ def _add_edges(self, builder: StateGraph):
78
+ """
79
+ Define as conexões entre os nós.
80
+
81
+ Args:
82
+ builder: Builder do StateGraph
83
+ """
84
+ # Ponto de entrada condicional
85
+ builder.add_conditional_edges(
86
+ START,
87
+ self._route_start,
88
+ {
89
+ "process_pdf": "load_pdf",
90
+ "answer_question": "retrieve_context"
91
+ }
92
+ )
93
+
94
+ # Fluxo de processamento de PDF
95
+ builder.add_edge("load_pdf", "process_text")
96
+ builder.add_edge("process_text", "create_embeddings")
97
+
98
+ # Após criar embeddings, vai para o fim (PDF processado)
99
+ builder.add_edge("create_embeddings", END)
100
+
101
+ # Fluxo de resposta a perguntas
102
+ builder.add_edge("retrieve_context", "llm_agent")
103
+ builder.add_edge("llm_agent", END)
104
+
105
+ log_graph_execution("EDGES", "Todas as conexões definidas")
106
+
107
+ def _route_start(self, state: PDFState) -> Literal["process_pdf", "answer_question"]:
108
+ """
109
+ Determina o ponto de entrada baseado no estado.
110
+
111
+ Args:
112
+ state: Estado atual do grafo
113
+
114
+ Returns:
115
+ str: Próximo nó a ser executado
116
+ """
117
+ # Se há um PDF para processar e ainda não foi processado
118
+ if state.get("pdf_path") and not state.get("embeddings_created", False):
119
+ log_graph_execution("ROUTE", "Direcionando para processamento de PDF")
120
+ return "process_pdf"
121
+
122
+ # Se há uma pergunta e o PDF já foi processado
123
+ if state.get("messages") and state.get("embeddings_created", False):
124
+ log_graph_execution("ROUTE", "Direcionando para resposta de pergunta")
125
+ return "answer_question"
126
+
127
+ # Fallback: processar PDF
128
+ log_graph_execution("ROUTE", "Fallback: direcionando para processamento de PDF")
129
+ return "process_pdf"
130
+
131
+ def process_pdf(self, pdf_path: str) -> dict:
132
+ """
133
+ Processa um arquivo PDF.
134
+
135
+ Args:
136
+ pdf_path: Caminho para o arquivo PDF
137
+
138
+ Returns:
139
+ dict: Resultado do processamento
140
+ """
141
+ log_graph_execution("PROCESS_PDF", f"Iniciando processamento: {pdf_path}")
142
+
143
+ try:
144
+ # Estado inicial para processamento
145
+ initial_state = {
146
+ "pdf_path": pdf_path,
147
+ "messages": [],
148
+ "embeddings_created": False,
149
+ "processing_status": ProcessingStatus.LOADING_PDF
150
+ }
151
+
152
+ # Executa o grafo
153
+ result = self.graph.invoke(initial_state)
154
+
155
+ # Verifica se o processamento foi bem-sucedido
156
+ if result.get("processing_status") == ProcessingStatus.ERROR:
157
+ error_msg = result.get("error_message", "Erro desconhecido")
158
+ log_graph_execution("PROCESS_PDF", f"ERRO: {error_msg}")
159
+ return {
160
+ "success": False,
161
+ "error": error_msg,
162
+ "result": result
163
+ }
164
+
165
+ log_graph_execution("PROCESS_PDF", "PDF processado com sucesso")
166
+ return {
167
+ "success": True,
168
+ "message": "PDF processado e indexado com sucesso!",
169
+ "result": result
170
+ }
171
+
172
+ except Exception as e:
173
+ error_msg = f"Erro no processamento do PDF: {str(e)}"
174
+ log_graph_execution("PROCESS_PDF", f"ERRO: {error_msg}")
175
+ main_logger.exception("Erro detalhado no processamento:")
176
+
177
+ return {
178
+ "success": False,
179
+ "error": error_msg,
180
+ "result": None
181
+ }
182
+
183
+ def ask_question(self, question: str, current_state: dict = None) -> dict:
184
+ """
185
+ Faz uma pergunta sobre o PDF processado.
186
+
187
+ Args:
188
+ question: Pergunta do usuário
189
+ current_state: Estado atual (opcional)
190
+
191
+ Returns:
192
+ dict: Resposta gerada
193
+ """
194
+ log_graph_execution("ASK_QUESTION", f"Pergunta: {question[:100]}...")
195
+
196
+ try:
197
+ # Verifica se há estado atual ou cria um novo
198
+ if current_state is None:
199
+ log_graph_execution("ASK_QUESTION", "ERRO: Nenhum estado fornecido")
200
+ return {
201
+ "success": False,
202
+ "error": "PDF não foi processado. Faça upload de um PDF primeiro.",
203
+ "answer": None
204
+ }
205
+
206
+ # Verifica se o PDF foi processado
207
+ if not current_state.get("embeddings_created", False):
208
+ return {
209
+ "success": False,
210
+ "error": "PDF não foi processado completamente. Tente novamente.",
211
+ "answer": None
212
+ }
213
+
214
+ # Adiciona a pergunta às mensagens
215
+ human_message = HumanMessage(content=question)
216
+ messages = current_state.get("messages", [])
217
+ messages.append(human_message)
218
+
219
+ # Estado para a pergunta
220
+ question_state = {
221
+ **current_state,
222
+ "messages": messages,
223
+ "user_question": question,
224
+ "processing_status": ProcessingStatus.RETRIEVING_CONTEXT
225
+ }
226
+
227
+ # Executa o grafo
228
+ result = self.graph.invoke(question_state)
229
+
230
+ # Verifica se houve erro
231
+ if result.get("processing_status") == ProcessingStatus.ERROR:
232
+ error_msg = result.get("error_message", "Erro desconhecido")
233
+ log_graph_execution("ASK_QUESTION", f"ERRO: {error_msg}")
234
+ return {
235
+ "success": False,
236
+ "error": error_msg,
237
+ "answer": None
238
+ }
239
+
240
+ # Extrai a resposta
241
+ answer = result.get("final_answer", "Não foi possível gerar uma resposta.")
242
+
243
+ log_graph_execution("ASK_QUESTION", f"Resposta gerada: {len(answer)} caracteres")
244
+
245
+ return {
246
+ "success": True,
247
+ "answer": answer,
248
+ "result": result
249
+ }
250
+
251
+ except Exception as e:
252
+ error_msg = f"Erro ao processar pergunta: {str(e)}"
253
+ log_graph_execution("ASK_QUESTION", f"ERRO: {error_msg}")
254
+ main_logger.exception("Erro detalhado na pergunta:")
255
+
256
+ return {
257
+ "success": False,
258
+ "error": error_msg,
259
+ "answer": None
260
+ }
261
+
262
+ def get_graph_visualization(self) -> str:
263
+ """
264
+ Retorna uma representação visual do grafo.
265
+
266
+ Returns:
267
+ str: Representação do grafo
268
+ """
269
+ try:
270
+ # Tenta gerar visualização se disponível
271
+ if hasattr(self.graph, 'get_graph'):
272
+ return str(self.graph.get_graph())
273
+ else:
274
+ return "Visualização não disponível"
275
+ except Exception as e:
276
+ main_logger.warning(f"Erro ao gerar visualização: {e}")
277
+ return "Erro na visualização do grafo"
278
+
279
+ def get_status(self) -> dict:
280
+ """
281
+ Retorna o status atual do grafo.
282
+
283
+ Returns:
284
+ dict: Status do grafo
285
+ """
286
+ return {
287
+ "graph_compiled": self.graph is not None,
288
+ "config_valid": Config.validate_config(),
289
+ "nodes_count": 5, # Número de nós no grafo
290
+ "ready": self.graph is not None and Config.validate_config()
291
+ }
292
+
293
+
294
+ # Instância global do grafo
295
+ agent_pdf_graph = AgentPDFGraph()
296
+
297
+
298
+ def get_agent_graph() -> AgentPDFGraph:
299
+ """
300
+ Retorna a instância global do grafo.
301
+
302
+ Returns:
303
+ AgentPDFGraph: Instância do grafo
304
+ """
305
+ return agent_pdf_graph
306
+
307
+
308
+ def process_pdf_file(pdf_path: str) -> dict:
309
+ """
310
+ Função de conveniência para processar um PDF.
311
+
312
+ Args:
313
+ pdf_path: Caminho para o arquivo PDF
314
+
315
+ Returns:
316
+ dict: Resultado do processamento
317
+ """
318
+ return agent_pdf_graph.process_pdf(pdf_path)
319
+
320
+
321
+ def ask_pdf_question(question: str, state: dict = None) -> dict:
322
+ """
323
+ Função de conveniência para fazer perguntas.
324
+
325
+ Args:
326
+ question: Pergunta do usuário
327
+ state: Estado atual do processamento
328
+
329
+ Returns:
330
+ dict: Resposta gerada
331
+ """
332
+ return agent_pdf_graph.ask_question(question, state)
nodes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Módulo de nós do LangGraph
nodes/context_retriever.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nó de recuperação de contexto para o AgentPDF.
3
+
4
+ Este nó é responsável por buscar documentos relevantes no vector store
5
+ baseado na pergunta do usuário para fornecer contexto ao LLM.
6
+ """
7
+
8
+ from typing import Dict, Any, List, Tuple
9
+ from langchain_community.vectorstores import FAISS
10
+ from langchain_core.runnables import RunnableConfig
11
+ from langchain_core.documents import Document
12
+ from langchain_core.messages import HumanMessage
13
+
14
+ from agents.state import PDFState, ProcessingStatus
15
+ from utils.config import Config
16
+ from utils.logger import log_node_execution, main_logger
17
+
18
+
19
+ def context_retrieval_node(state: PDFState, config: RunnableConfig) -> Dict[str, Any]:
20
+ """
21
+ Nó responsável por recuperar contexto relevante para a pergunta.
22
+
23
+ Este nó:
24
+ 1. Extrai a pergunta do usuário das mensagens
25
+ 2. Busca documentos relevantes no vector store
26
+ 3. Seleciona e otimiza o contexto
27
+ 4. Atualiza o estado com o contexto recuperado
28
+
29
+ Args:
30
+ state: Estado atual do grafo
31
+ config: Configuração do LangGraph
32
+
33
+ Returns:
34
+ Dict[str, Any]: Atualizações para o estado
35
+ """
36
+ log_node_execution("CONTEXT_RETRIEVER", "START", "Iniciando recuperação de contexto")
37
+
38
+ try:
39
+ # Verifica se o vector store existe
40
+ vector_store = state.get("vector_store")
41
+ if not vector_store:
42
+ error_msg = "Vector store não encontrado. Execute o processamento do PDF primeiro."
43
+ log_node_execution("CONTEXT_RETRIEVER", "ERROR", error_msg)
44
+ return {
45
+ "processing_status": ProcessingStatus.ERROR,
46
+ "error_message": error_msg
47
+ }
48
+
49
+ # Extrai a pergunta do usuário
50
+ user_question = extract_user_question(state)
51
+ if not user_question:
52
+ error_msg = "Nenhuma pergunta encontrada nas mensagens"
53
+ log_node_execution("CONTEXT_RETRIEVER", "ERROR", error_msg)
54
+ return {
55
+ "processing_status": ProcessingStatus.ERROR,
56
+ "error_message": error_msg
57
+ }
58
+
59
+ log_node_execution(
60
+ "CONTEXT_RETRIEVER",
61
+ "PROCESSING",
62
+ f"Buscando contexto para: '{user_question[:100]}...'"
63
+ )
64
+
65
+ # Busca documentos relevantes
66
+ relevant_docs = retrieve_relevant_documents(vector_store, user_question)
67
+
68
+ if not relevant_docs:
69
+ log_node_execution(
70
+ "CONTEXT_RETRIEVER",
71
+ "SUCCESS",
72
+ "Nenhum contexto específico encontrado, usando busca ampla"
73
+ )
74
+ # Tenta uma busca mais ampla
75
+ relevant_docs = retrieve_relevant_documents(
76
+ vector_store,
77
+ user_question,
78
+ k=10,
79
+ use_broad_search=True
80
+ )
81
+
82
+ # Processa e otimiza o contexto
83
+ context_text = process_retrieved_context(relevant_docs, user_question)
84
+
85
+ log_node_execution(
86
+ "CONTEXT_RETRIEVER",
87
+ "SUCCESS",
88
+ f"Contexto recuperado: {len(relevant_docs)} documentos, {len(context_text)} caracteres"
89
+ )
90
+
91
+ return {
92
+ "retrieved_context": [doc.page_content for doc in relevant_docs],
93
+ "user_question": user_question,
94
+ "processing_status": ProcessingStatus.GENERATING_RESPONSE,
95
+ "error_message": None
96
+ }
97
+
98
+ except Exception as e:
99
+ error_msg = f"Erro na recuperação de contexto: {str(e)}"
100
+ log_node_execution("CONTEXT_RETRIEVER", "ERROR", error_msg)
101
+ main_logger.exception("Erro detalhado na recuperação de contexto:")
102
+
103
+ return {
104
+ "processing_status": ProcessingStatus.ERROR,
105
+ "error_message": error_msg
106
+ }
107
+
108
+
109
+ def extract_user_question(state: PDFState) -> str:
110
+ """
111
+ Extrai a pergunta do usuário das mensagens.
112
+
113
+ Args:
114
+ state: Estado atual contendo as mensagens
115
+
116
+ Returns:
117
+ str: Pergunta do usuário
118
+ """
119
+ messages = state.get("messages", [])
120
+
121
+ # Procura pela última mensagem humana
122
+ for message in reversed(messages):
123
+ if isinstance(message, HumanMessage):
124
+ return message.content.strip()
125
+
126
+ # Fallback: verifica se há pergunta direta no estado
127
+ user_question = state.get("user_question")
128
+ if user_question:
129
+ return user_question.strip()
130
+
131
+ return ""
132
+
133
+
134
+ def retrieve_relevant_documents(
135
+ vector_store: FAISS,
136
+ query: str,
137
+ k: int = None,
138
+ use_broad_search: bool = False
139
+ ) -> List[Document]:
140
+ """
141
+ Busca documentos relevantes no vector store.
142
+
143
+ Args:
144
+ vector_store: Vector store FAISS
145
+ query: Pergunta do usuário
146
+ k: Número de documentos para retornar
147
+ use_broad_search: Se deve usar busca mais ampla
148
+
149
+ Returns:
150
+ List[Document]: Lista de documentos relevantes
151
+ """
152
+ try:
153
+ # Configurações de busca
154
+ config = Config.get_retrieval_config()
155
+ search_k = k or config["k"]
156
+
157
+ if use_broad_search:
158
+ search_k = min(search_k * 2, 15) # Busca mais ampla
159
+
160
+ # Busca com scores de similaridade
161
+ docs_with_scores = vector_store.similarity_search_with_score(
162
+ query,
163
+ k=search_k
164
+ )
165
+
166
+ # Filtra por threshold de similaridade se não for busca ampla
167
+ if not use_broad_search:
168
+ threshold = config["score_threshold"]
169
+ filtered_docs = [
170
+ doc for doc, score in docs_with_scores
171
+ if score <= threshold # FAISS usa distância (menor = mais similar)
172
+ ]
173
+ else:
174
+ # Na busca ampla, aceita mais documentos
175
+ filtered_docs = [doc for doc, score in docs_with_scores]
176
+
177
+ # Log da busca
178
+ main_logger.debug(f"Busca retornou {len(docs_with_scores)} documentos")
179
+ main_logger.debug(f"Após filtragem: {len(filtered_docs)} documentos")
180
+
181
+ if docs_with_scores:
182
+ best_score = docs_with_scores[0][1]
183
+ main_logger.debug(f"Melhor score de similaridade: {best_score:.4f}")
184
+
185
+ return filtered_docs
186
+
187
+ except Exception as e:
188
+ main_logger.error(f"Erro na busca de documentos: {e}")
189
+ return []
190
+
191
+
192
+ def process_retrieved_context(documents: List[Document], query: str) -> str:
193
+ """
194
+ Processa e otimiza o contexto recuperado.
195
+
196
+ Args:
197
+ documents: Lista de documentos recuperados
198
+ query: Pergunta original do usuário
199
+
200
+ Returns:
201
+ str: Contexto processado e otimizado
202
+ """
203
+ if not documents:
204
+ return ""
205
+
206
+ # Ordena documentos por relevância (se tiver scores)
207
+ sorted_docs = rank_documents_by_relevance(documents, query)
208
+
209
+ # Combina o contexto
210
+ context_parts = []
211
+ total_length = 0
212
+ max_context_length = 4000 # Limite para não sobrecarregar o LLM
213
+
214
+ for i, doc in enumerate(sorted_docs):
215
+ content = doc.page_content.strip()
216
+
217
+ # Verifica se ainda cabe no limite
218
+ if total_length + len(content) > max_context_length:
219
+ # Tenta adicionar uma versão truncada
220
+ remaining_space = max_context_length - total_length
221
+ if remaining_space > 200: # Só adiciona se sobrar espaço significativo
222
+ truncated_content = content[:remaining_space-50] + "..."
223
+ context_parts.append(f"[Documento {i+1}]\n{truncated_content}")
224
+ break
225
+
226
+ context_parts.append(f"[Documento {i+1}]\n{content}")
227
+ total_length += len(content)
228
+
229
+ # Junta o contexto
230
+ final_context = "\n\n".join(context_parts)
231
+
232
+ main_logger.debug(f"Contexto final: {len(final_context)} caracteres de {len(documents)} documentos")
233
+
234
+ return final_context
235
+
236
+
237
+ def rank_documents_by_relevance(documents: List[Document], query: str) -> List[Document]:
238
+ """
239
+ Ordena documentos por relevância à pergunta.
240
+
241
+ Args:
242
+ documents: Lista de documentos
243
+ query: Pergunta do usuário
244
+
245
+ Returns:
246
+ List[Document]: Documentos ordenados por relevância
247
+ """
248
+ # Para uma implementação simples, vamos usar a ordem original
249
+ # Em uma versão mais avançada, poderíamos implementar re-ranking
250
+
251
+ # Calcula scores simples baseados em palavras-chave
252
+ query_words = set(query.lower().split())
253
+
254
+ def calculate_relevance_score(doc: Document) -> float:
255
+ content_words = set(doc.page_content.lower().split())
256
+
257
+ # Conta palavras em comum
258
+ common_words = query_words.intersection(content_words)
259
+
260
+ # Score baseado na proporção de palavras em comum
261
+ if len(query_words) == 0:
262
+ return 0.0
263
+
264
+ return len(common_words) / len(query_words)
265
+
266
+ # Ordena por score de relevância (decrescente)
267
+ scored_docs = [(doc, calculate_relevance_score(doc)) for doc in documents]
268
+ scored_docs.sort(key=lambda x: x[1], reverse=True)
269
+
270
+ # Log dos scores para debug
271
+ for i, (doc, score) in enumerate(scored_docs[:3]):
272
+ main_logger.debug(f"Doc {i+1} relevance score: {score:.3f}")
273
+
274
+ return [doc for doc, score in scored_docs]
275
+
276
+
277
+ def enhance_query_for_retrieval(query: str) -> str:
278
+ """
279
+ Melhora a query para melhor recuperação.
280
+
281
+ Args:
282
+ query: Query original
283
+
284
+ Returns:
285
+ str: Query melhorada
286
+ """
287
+ # Remove palavras muito comuns que podem atrapalhar a busca
288
+ stop_words = {
289
+ 'o', 'a', 'os', 'as', 'um', 'uma', 'uns', 'umas',
290
+ 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na',
291
+ 'nos', 'nas', 'por', 'para', 'com', 'sem', 'sobre',
292
+ 'que', 'qual', 'quais', 'como', 'quando', 'onde',
293
+ 'é', 'são', 'foi', 'foram', 'ser', 'estar'
294
+ }
295
+
296
+ # Mantém apenas palavras significativas
297
+ words = query.lower().split()
298
+ meaningful_words = [word for word in words if word not in stop_words and len(word) > 2]
299
+
300
+ enhanced_query = ' '.join(meaningful_words)
301
+
302
+ if enhanced_query != query.lower():
303
+ main_logger.debug(f"Query melhorada: '{query}' -> '{enhanced_query}'")
304
+
305
+ return enhanced_query if enhanced_query else query
306
+
307
+
308
+ def get_retrieval_statistics(documents: List[Document]) -> Dict[str, Any]:
309
+ """
310
+ Calcula estatísticas da recuperação.
311
+
312
+ Args:
313
+ documents: Documentos recuperados
314
+
315
+ Returns:
316
+ Dict[str, Any]: Estatísticas da recuperação
317
+ """
318
+ if not documents:
319
+ return {
320
+ "total_documents": 0,
321
+ "total_characters": 0,
322
+ "average_length": 0
323
+ }
324
+
325
+ lengths = [len(doc.page_content) for doc in documents]
326
+
327
+ return {
328
+ "total_documents": len(documents),
329
+ "total_characters": sum(lengths),
330
+ "average_length": sum(lengths) / len(lengths),
331
+ "min_length": min(lengths),
332
+ "max_length": max(lengths)
333
+ }
nodes/embeddings_creator.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nó de criação de embeddings e vector store para o AgentPDF.
3
+
4
+ Este nó é responsável por gerar embeddings dos chunks de texto
5
+ e criar um vector store FAISS para recuperação eficiente.
6
+ """
7
+
8
+ from typing import Dict, Any, List
9
+ from langchain_openai import OpenAIEmbeddings
10
+ from langchain_community.vectorstores import FAISS
11
+ from langchain_core.runnables import RunnableConfig
12
+ from langchain_core.documents import Document
13
+
14
+ from agents.state import PDFState, ProcessingStatus
15
+ from utils.config import Config, get_openai_api_key
16
+ from utils.logger import log_node_execution, main_logger
17
+
18
+
19
+ def embeddings_creation_node(state: PDFState, config: RunnableConfig) -> Dict[str, Any]:
20
+ """
21
+ Nó responsável por criar embeddings e vector store.
22
+
23
+ Este nó:
24
+ 1. Recebe os chunks de texto processados
25
+ 2. Gera embeddings usando OpenAI
26
+ 3. Cria um vector store FAISS
27
+ 4. Atualiza o estado com o vector store
28
+
29
+ Args:
30
+ state: Estado atual do grafo contendo os chunks
31
+ config: Configuração do LangGraph
32
+
33
+ Returns:
34
+ Dict[str, Any]: Atualizações para o estado
35
+ """
36
+ log_node_execution("EMBEDDINGS_CREATOR", "START", "Iniciando criação de embeddings")
37
+
38
+ try:
39
+ # Verifica se há chunks para processar
40
+ pdf_chunks = state.get("pdf_chunks")
41
+ if not pdf_chunks:
42
+ error_msg = "Nenhum chunk encontrado para criar embeddings"
43
+ log_node_execution("EMBEDDINGS_CREATOR", "ERROR", error_msg)
44
+ return {
45
+ "processing_status": ProcessingStatus.ERROR,
46
+ "error_message": error_msg
47
+ }
48
+
49
+ # Verifica se a API key está configurada
50
+ api_key = get_openai_api_key()
51
+ if not api_key:
52
+ error_msg = "Chave da API OpenAI não configurada"
53
+ log_node_execution("EMBEDDINGS_CREATOR", "ERROR", error_msg)
54
+ return {
55
+ "processing_status": ProcessingStatus.ERROR,
56
+ "error_message": error_msg
57
+ }
58
+
59
+ log_node_execution(
60
+ "EMBEDDINGS_CREATOR",
61
+ "PROCESSING",
62
+ f"Criando embeddings para {len(pdf_chunks)} chunks"
63
+ )
64
+
65
+ # Cria o modelo de embeddings
66
+ embeddings_model = create_embeddings_model()
67
+
68
+ # Converte chunks em documentos
69
+ documents = create_documents_from_chunks(pdf_chunks)
70
+
71
+ # Cria o vector store
72
+ vector_store = create_vector_store(documents, embeddings_model)
73
+
74
+ log_node_execution(
75
+ "EMBEDDINGS_CREATOR",
76
+ "SUCCESS",
77
+ f"Vector store criado com {len(documents)} documentos"
78
+ )
79
+
80
+ return {
81
+ "vector_store": vector_store,
82
+ "embeddings_created": True,
83
+ "processing_status": ProcessingStatus.IDLE, # Pronto para perguntas
84
+ "error_message": None
85
+ }
86
+
87
+ except Exception as e:
88
+ error_msg = f"Erro ao criar embeddings: {str(e)}"
89
+ log_node_execution("EMBEDDINGS_CREATOR", "ERROR", error_msg)
90
+ main_logger.exception("Erro detalhado na criação de embeddings:")
91
+
92
+ return {
93
+ "processing_status": ProcessingStatus.ERROR,
94
+ "error_message": error_msg,
95
+ "embeddings_created": False
96
+ }
97
+
98
+
99
+ def create_embeddings_model() -> OpenAIEmbeddings:
100
+ """
101
+ Cria e configura o modelo de embeddings OpenAI.
102
+
103
+ Returns:
104
+ OpenAIEmbeddings: Modelo de embeddings configurado
105
+ """
106
+ try:
107
+ embeddings = OpenAIEmbeddings(
108
+ openai_api_key=get_openai_api_key(),
109
+ model="text-embedding-3-small", # Modelo mais eficiente
110
+ chunk_size=1000, # Tamanho do chunk para embeddings
111
+ max_retries=3,
112
+ timeout=30
113
+ )
114
+
115
+ main_logger.debug("Modelo de embeddings OpenAI criado com sucesso")
116
+ return embeddings
117
+
118
+ except Exception as e:
119
+ main_logger.error(f"Erro ao criar modelo de embeddings: {e}")
120
+ raise
121
+
122
+
123
+ def create_documents_from_chunks(chunks: List[str]) -> List[Document]:
124
+ """
125
+ Converte chunks de texto em objetos Document do LangChain.
126
+
127
+ Args:
128
+ chunks: Lista de chunks de texto
129
+
130
+ Returns:
131
+ List[Document]: Lista de documentos LangChain
132
+ """
133
+ documents = []
134
+
135
+ for i, chunk in enumerate(chunks):
136
+ # Cria metadados para cada documento
137
+ metadata = {
138
+ "chunk_id": i,
139
+ "chunk_size": len(chunk),
140
+ "source": "pdf_upload",
141
+ "chunk_index": i
142
+ }
143
+
144
+ # Cria o documento
145
+ doc = Document(
146
+ page_content=chunk,
147
+ metadata=metadata
148
+ )
149
+
150
+ documents.append(doc)
151
+
152
+ main_logger.debug(f"Criados {len(documents)} documentos a partir dos chunks")
153
+ return documents
154
+
155
+
156
+ def create_vector_store(documents: List[Document], embeddings_model: OpenAIEmbeddings) -> FAISS:
157
+ """
158
+ Cria um vector store FAISS a partir dos documentos.
159
+
160
+ Args:
161
+ documents: Lista de documentos
162
+ embeddings_model: Modelo de embeddings
163
+
164
+ Returns:
165
+ FAISS: Vector store criado
166
+ """
167
+ try:
168
+ main_logger.info("Criando vector store FAISS...")
169
+
170
+ # Cria o vector store
171
+ vector_store = FAISS.from_documents(
172
+ documents=documents,
173
+ embedding=embeddings_model
174
+ )
175
+
176
+ main_logger.info(f"Vector store FAISS criado com {len(documents)} documentos")
177
+
178
+ # Log estatísticas
179
+ log_vector_store_stats(vector_store, documents)
180
+
181
+ return vector_store
182
+
183
+ except Exception as e:
184
+ main_logger.error(f"Erro ao criar vector store FAISS: {e}")
185
+ raise
186
+
187
+
188
+ def log_vector_store_stats(vector_store: FAISS, documents: List[Document]):
189
+ """
190
+ Registra estatísticas do vector store criado.
191
+
192
+ Args:
193
+ vector_store: Vector store FAISS
194
+ documents: Lista de documentos
195
+ """
196
+ try:
197
+ # Estatísticas básicas
198
+ total_docs = len(documents)
199
+ total_chars = sum(len(doc.page_content) for doc in documents)
200
+ avg_doc_size = total_chars / total_docs if total_docs > 0 else 0
201
+
202
+ main_logger.info(f"📊 Estatísticas do Vector Store:")
203
+ main_logger.info(f" • Total de documentos: {total_docs}")
204
+ main_logger.info(f" • Total de caracteres: {total_chars:,}")
205
+ main_logger.info(f" • Tamanho médio por documento: {avg_doc_size:.0f} caracteres")
206
+
207
+ # Testa uma busca simples para verificar funcionamento
208
+ test_results = vector_store.similarity_search("teste", k=1)
209
+ main_logger.debug(f"Teste de busca retornou {len(test_results)} resultado(s)")
210
+
211
+ except Exception as e:
212
+ main_logger.warning(f"Erro ao calcular estatísticas do vector store: {e}")
213
+
214
+
215
+ def test_vector_store(vector_store: FAISS, test_query: str = "informação") -> bool:
216
+ """
217
+ Testa o funcionamento do vector store.
218
+
219
+ Args:
220
+ vector_store: Vector store para testar
221
+ test_query: Query de teste
222
+
223
+ Returns:
224
+ bool: True se o teste passou
225
+ """
226
+ try:
227
+ # Testa busca por similaridade
228
+ results = vector_store.similarity_search(test_query, k=3)
229
+
230
+ if not results:
231
+ main_logger.warning("Vector store não retornou resultados para query de teste")
232
+ return False
233
+
234
+ # Testa busca com score
235
+ results_with_score = vector_store.similarity_search_with_score(test_query, k=3)
236
+
237
+ if not results_with_score:
238
+ main_logger.warning("Vector store não retornou scores para query de teste")
239
+ return False
240
+
241
+ main_logger.debug(f"Teste do vector store passou: {len(results)} resultados encontrados")
242
+ return True
243
+
244
+ except Exception as e:
245
+ main_logger.error(f"Erro no teste do vector store: {e}")
246
+ return False
247
+
248
+
249
+ def optimize_vector_store(vector_store: FAISS) -> FAISS:
250
+ """
251
+ Otimiza o vector store para melhor performance.
252
+
253
+ Args:
254
+ vector_store: Vector store original
255
+
256
+ Returns:
257
+ FAISS: Vector store otimizado
258
+ """
259
+ try:
260
+ # Para FAISS, podemos otimizar o índice
261
+ # Isso é especialmente útil para grandes volumes de dados
262
+
263
+ main_logger.debug("Otimizando vector store FAISS...")
264
+
265
+ # O FAISS já é otimizado por padrão para volumes pequenos/médios
266
+ # Para volumes maiores, poderíamos usar índices mais sofisticados
267
+
268
+ return vector_store
269
+
270
+ except Exception as e:
271
+ main_logger.warning(f"Erro na otimização do vector store: {e}")
272
+ return vector_store # Retorna o original se a otimização falhar
273
+
274
+
275
+ def get_vector_store_info(vector_store: FAISS) -> Dict[str, Any]:
276
+ """
277
+ Obtém informações sobre o vector store.
278
+
279
+ Args:
280
+ vector_store: Vector store FAISS
281
+
282
+ Returns:
283
+ Dict[str, Any]: Informações do vector store
284
+ """
285
+ try:
286
+ # Informações básicas do FAISS
287
+ index = vector_store.index
288
+
289
+ return {
290
+ "total_vectors": index.ntotal,
291
+ "vector_dimension": index.d,
292
+ "index_type": type(index).__name__,
293
+ "is_trained": index.is_trained if hasattr(index, 'is_trained') else True
294
+ }
295
+
296
+ except Exception as e:
297
+ main_logger.warning(f"Erro ao obter informações do vector store: {e}")
298
+ return {
299
+ "total_vectors": 0,
300
+ "vector_dimension": 0,
301
+ "index_type": "unknown",
302
+ "is_trained": False
303
+ }
nodes/llm_agent.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nó do agente LLM para o AgentPDF.
3
+
4
+ Este nó é responsável por gerar respostas inteligentes usando GPT-4o-mini
5
+ baseadas no contexto recuperado do PDF e na pergunta do usuário.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+ from langchain_openai import ChatOpenAI
10
+ from langchain_core.messages import AIMessage, SystemMessage, HumanMessage
11
+ from langchain_core.runnables import RunnableConfig
12
+ from langchain_core.prompts import ChatPromptTemplate
13
+
14
+ from agents.state import PDFState, ProcessingStatus
15
+ from utils.config import Config, get_openai_api_key
16
+ from utils.logger import log_node_execution, main_logger
17
+
18
+
19
+ def llm_agent_node(state: PDFState, config: RunnableConfig) -> Dict[str, Any]:
20
+ """
21
+ Nó responsável por gerar respostas usando o LLM.
22
+
23
+ Este nó:
24
+ 1. Recebe a pergunta e o contexto recuperado
25
+ 2. Constrói um prompt otimizado
26
+ 3. Chama o GPT-4o-mini para gerar a resposta
27
+ 4. Processa e valida a resposta
28
+ 5. Atualiza o estado com a resposta final
29
+
30
+ Args:
31
+ state: Estado atual do grafo
32
+ config: Configuração do LangGraph
33
+
34
+ Returns:
35
+ Dict[str, Any]: Atualizações para o estado
36
+ """
37
+ log_node_execution("LLM_AGENT", "START", "Iniciando geração de resposta")
38
+
39
+ try:
40
+ # Verifica se há pergunta e contexto
41
+ user_question = state.get("user_question")
42
+ retrieved_context = state.get("retrieved_context", [])
43
+
44
+ if not user_question:
45
+ error_msg = "Pergunta do usuário não encontrada"
46
+ log_node_execution("LLM_AGENT", "ERROR", error_msg)
47
+ return {
48
+ "processing_status": ProcessingStatus.ERROR,
49
+ "error_message": error_msg
50
+ }
51
+
52
+ # Verifica API key
53
+ api_key = get_openai_api_key()
54
+ if not api_key:
55
+ error_msg = "Chave da API OpenAI não configurada"
56
+ log_node_execution("LLM_AGENT", "ERROR", error_msg)
57
+ return {
58
+ "processing_status": ProcessingStatus.ERROR,
59
+ "error_message": error_msg
60
+ }
61
+
62
+ log_node_execution(
63
+ "LLM_AGENT",
64
+ "PROCESSING",
65
+ f"Gerando resposta para: '{user_question[:100]}...'"
66
+ )
67
+
68
+ # Cria o modelo LLM
69
+ llm = create_llm_model()
70
+
71
+ # Constrói o prompt
72
+ prompt = build_prompt(user_question, retrieved_context)
73
+
74
+ # Gera a resposta
75
+ response = generate_response(llm, prompt)
76
+
77
+ # Processa a resposta
78
+ final_answer = process_response(response, user_question)
79
+
80
+ # Cria mensagem de resposta
81
+ ai_message = AIMessage(content=final_answer)
82
+
83
+ log_node_execution(
84
+ "LLM_AGENT",
85
+ "SUCCESS",
86
+ f"Resposta gerada: {len(final_answer)} caracteres"
87
+ )
88
+
89
+ return {
90
+ "final_answer": final_answer,
91
+ "messages": [ai_message],
92
+ "processing_status": ProcessingStatus.COMPLETED,
93
+ "error_message": None
94
+ }
95
+
96
+ except Exception as e:
97
+ error_msg = f"Erro na geração de resposta: {str(e)}"
98
+ log_node_execution("LLM_AGENT", "ERROR", error_msg)
99
+ main_logger.exception("Erro detalhado na geração de resposta:")
100
+
101
+ return {
102
+ "processing_status": ProcessingStatus.ERROR,
103
+ "error_message": error_msg
104
+ }
105
+
106
+
107
+ def create_llm_model() -> ChatOpenAI:
108
+ """
109
+ Cria e configura o modelo LLM GPT-4o-mini.
110
+
111
+ Returns:
112
+ ChatOpenAI: Modelo LLM configurado
113
+ """
114
+ model_config = Config.get_model_config()
115
+
116
+ llm = ChatOpenAI(
117
+ openai_api_key=get_openai_api_key(),
118
+ model_name=model_config["model"],
119
+ temperature=model_config["temperature"],
120
+ max_tokens=model_config["max_tokens"],
121
+ timeout=60,
122
+ max_retries=3
123
+ )
124
+
125
+ main_logger.debug(f"Modelo LLM criado: {model_config['model']}")
126
+ return llm
127
+
128
+
129
+ def build_prompt(question: str, context_chunks: list) -> ChatPromptTemplate:
130
+ """
131
+ Constrói um prompt otimizado para o LLM.
132
+
133
+ Args:
134
+ question: Pergunta do usuário
135
+ context_chunks: Lista de chunks de contexto
136
+
137
+ Returns:
138
+ ChatPromptTemplate: Prompt construído
139
+ """
140
+ # Combina o contexto
141
+ context_text = "\n\n".join(context_chunks) if context_chunks else ""
142
+
143
+ # Sistema de prompt em português
144
+ system_prompt = """Você é um assistente especializado em análise de documentos PDF. Sua função é responder perguntas baseadas exclusivamente no conteúdo fornecido.
145
+
146
+ INSTRUÇÕES IMPORTANTES:
147
+ 1. Use APENAS as informações do contexto fornecido para responder
148
+ 2. Se a informação não estiver no contexto, diga claramente que não encontrou a informação no documento
149
+ 3. Seja preciso, claro e objetivo em suas respostas
150
+ 4. Cite trechos relevantes do documento quando apropriado
151
+ 5. Mantenha um tom profissional e educativo
152
+ 6. Se a pergunta for ambígua, peça esclarecimentos
153
+ 7. Organize sua resposta de forma estruturada quando necessário
154
+
155
+ FORMATO DA RESPOSTA:
156
+ - Responda diretamente à pergunta
157
+ - Use parágrafos para organizar ideias complexas
158
+ - Inclua citações do documento quando relevante
159
+ - Termine com um resumo se a resposta for longa"""
160
+
161
+ # Template do prompt
162
+ prompt_template = ChatPromptTemplate.from_messages([
163
+ ("system", system_prompt),
164
+ ("human", """CONTEXTO DO DOCUMENTO:
165
+ {context}
166
+
167
+ PERGUNTA DO USUÁRIO:
168
+ {question}
169
+
170
+ Por favor, responda à pergunta baseando-se exclusivamente no contexto fornecido.""")
171
+ ])
172
+
173
+ return prompt_template.partial(context=context_text, question=question)
174
+
175
+
176
+ def generate_response(llm: ChatOpenAI, prompt: ChatPromptTemplate) -> str:
177
+ """
178
+ Gera a resposta usando o LLM.
179
+
180
+ Args:
181
+ llm: Modelo LLM
182
+ prompt: Prompt construído
183
+
184
+ Returns:
185
+ str: Resposta gerada
186
+ """
187
+ try:
188
+ # Cria a chain
189
+ chain = prompt | llm
190
+
191
+ # Gera a resposta
192
+ response = chain.invoke({})
193
+
194
+ # Extrai o conteúdo da resposta
195
+ if hasattr(response, 'content'):
196
+ return response.content
197
+ else:
198
+ return str(response)
199
+
200
+ except Exception as e:
201
+ main_logger.error(f"Erro na geração da resposta: {e}")
202
+ raise
203
+
204
+
205
+ def process_response(response: str, original_question: str) -> str:
206
+ """
207
+ Processa e valida a resposta gerada.
208
+
209
+ Args:
210
+ response: Resposta bruta do LLM
211
+ original_question: Pergunta original do usuário
212
+
213
+ Returns:
214
+ str: Resposta processada e validada
215
+ """
216
+ if not response or not response.strip():
217
+ return "Desculpe, não consegui gerar uma resposta adequada para sua pergunta."
218
+
219
+ # Limpa a resposta
220
+ cleaned_response = response.strip()
221
+
222
+ # Valida se a resposta é adequada
223
+ if len(cleaned_response) < 20:
224
+ return f"Resposta muito curta gerada. Pergunta original: {original_question}\n\nResposta: {cleaned_response}"
225
+
226
+ # Adiciona informações contextuais se necessário
227
+ if "não encontrei" in cleaned_response.lower() or "não há informação" in cleaned_response.lower():
228
+ cleaned_response += "\n\n💡 **Dica**: Tente reformular sua pergunta ou verificar se o PDF contém a informação desejada."
229
+
230
+ return cleaned_response
231
+
232
+
233
+ def create_fallback_response(question: str, error_msg: str = None) -> str:
234
+ """
235
+ Cria uma resposta de fallback quando há erro.
236
+
237
+ Args:
238
+ question: Pergunta original
239
+ error_msg: Mensagem de erro opcional
240
+
241
+ Returns:
242
+ str: Resposta de fallback
243
+ """
244
+ base_response = f"""Desculpe, encontrei dificuldades para processar sua pergunta: "{question}"
245
+
246
+ Isso pode ter acontecido por alguns motivos:
247
+ 1. O documento PDF pode não conter informações relacionadas à sua pergunta
248
+ 2. Pode haver um problema temporário com o processamento
249
+ 3. A pergunta pode precisar ser mais específica
250
+
251
+ **Sugestões:**
252
+ - Tente reformular sua pergunta de forma mais específica
253
+ - Verifique se o PDF foi carregado corretamente
254
+ - Certifique-se de que o documento contém a informação desejada"""
255
+
256
+ if error_msg:
257
+ base_response += f"\n\n**Detalhes técnicos:** {error_msg}"
258
+
259
+ return base_response
260
+
261
+
262
+ def validate_response_quality(response: str, question: str) -> tuple[bool, str]:
263
+ """
264
+ Valida a qualidade da resposta gerada.
265
+
266
+ Args:
267
+ response: Resposta gerada
268
+ question: Pergunta original
269
+
270
+ Returns:
271
+ tuple[bool, str]: (é_válida, motivo_se_inválida)
272
+ """
273
+ if not response or len(response.strip()) < 10:
274
+ return False, "Resposta muito curta ou vazia"
275
+
276
+ # Verifica se a resposta é apenas uma repetição da pergunta
277
+ if question.lower() in response.lower() and len(response) < len(question) * 2:
278
+ return False, "Resposta parece ser apenas repetição da pergunta"
279
+
280
+ # Verifica se há conteúdo substantivo
281
+ words = response.split()
282
+ if len(words) < 5:
283
+ return False, "Resposta com muito poucas palavras"
284
+
285
+ # Verifica padrões de resposta inadequada
286
+ inadequate_patterns = [
287
+ "não posso responder",
288
+ "não tenho informação",
289
+ "desculpe, mas não",
290
+ "não é possível"
291
+ ]
292
+
293
+ response_lower = response.lower()
294
+ inadequate_count = sum(1 for pattern in inadequate_patterns if pattern in response_lower)
295
+
296
+ if inadequate_count > 1:
297
+ return False, "Resposta contém muitos padrões de inadequação"
298
+
299
+ return True, "Resposta válida"
300
+
301
+
302
+ def enhance_response_with_metadata(response: str, context_used: bool, num_sources: int) -> str:
303
+ """
304
+ Melhora a resposta adicionando metadados úteis.
305
+
306
+ Args:
307
+ response: Resposta original
308
+ context_used: Se contexto foi usado
309
+ num_sources: Número de fontes consultadas
310
+
311
+ Returns:
312
+ str: Resposta melhorada
313
+ """
314
+ enhanced_response = response
315
+
316
+ # Adiciona informação sobre as fontes
317
+ if context_used and num_sources > 0:
318
+ enhanced_response += f"\n\n---\n📚 *Resposta baseada em {num_sources} seção(ões) do documento.*"
319
+ elif not context_used:
320
+ enhanced_response += "\n\n---\n⚠️ *Resposta gerada sem contexto específico do documento.*"
321
+
322
+ return enhanced_response
nodes/pdf_loader.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nó de carregamento de PDF para o AgentPDF.
3
+
4
+ Este nó é responsável por carregar e extrair texto de arquivos PDF
5
+ usando PyPDF2 e preparar o conteúdo para processamento posterior.
6
+ """
7
+
8
+ import os
9
+ from typing import Dict, Any
10
+ from PyPDF2 import PdfReader
11
+ from langchain_core.runnables import RunnableConfig
12
+
13
+ from agents.state import PDFState, ProcessingStatus
14
+ from utils.logger import log_node_execution, main_logger
15
+
16
+
17
+ def load_pdf_node(state: PDFState, config: RunnableConfig) -> Dict[str, Any]:
18
+ """
19
+ Nó responsável por carregar e extrair texto de arquivos PDF.
20
+
21
+ Este nó:
22
+ 1. Verifica se o caminho do PDF é válido
23
+ 2. Carrega o PDF usando PyPDF2
24
+ 3. Extrai todo o texto do documento
25
+ 4. Atualiza o estado com o texto extraído
26
+
27
+ Args:
28
+ state: Estado atual do grafo contendo informações do PDF
29
+ config: Configuração do LangGraph
30
+
31
+ Returns:
32
+ Dict[str, Any]: Atualizações para o estado
33
+ """
34
+ log_node_execution("PDF_LOADER", "START", "Iniciando carregamento do PDF")
35
+
36
+ try:
37
+ # Verifica se o caminho do PDF foi fornecido
38
+ pdf_path = state.get("pdf_path")
39
+ if not pdf_path:
40
+ error_msg = "Caminho do PDF não fornecido"
41
+ log_node_execution("PDF_LOADER", "ERROR", error_msg)
42
+ return {
43
+ "processing_status": ProcessingStatus.ERROR,
44
+ "error_message": error_msg
45
+ }
46
+
47
+ # Verifica se o arquivo existe
48
+ if not os.path.exists(pdf_path):
49
+ error_msg = f"Arquivo PDF não encontrado: {pdf_path}"
50
+ log_node_execution("PDF_LOADER", "ERROR", error_msg)
51
+ return {
52
+ "processing_status": ProcessingStatus.ERROR,
53
+ "error_message": error_msg
54
+ }
55
+
56
+ # Atualiza status para carregamento
57
+ log_node_execution("PDF_LOADER", "PROCESSING", f"Carregando PDF: {pdf_path}")
58
+
59
+ # Carrega e extrai texto do PDF
60
+ extracted_text = extract_text_from_pdf(pdf_path)
61
+
62
+ if not extracted_text.strip():
63
+ error_msg = "Nenhum texto foi extraído do PDF"
64
+ log_node_execution("PDF_LOADER", "ERROR", error_msg)
65
+ return {
66
+ "processing_status": ProcessingStatus.ERROR,
67
+ "error_message": error_msg
68
+ }
69
+
70
+ # Sucesso - retorna texto extraído
71
+ log_node_execution(
72
+ "PDF_LOADER",
73
+ "SUCCESS",
74
+ f"Texto extraído com sucesso. Tamanho: {len(extracted_text)} caracteres"
75
+ )
76
+
77
+ return {
78
+ "pdf_text": extracted_text,
79
+ "processing_status": ProcessingStatus.PROCESSING_TEXT,
80
+ "error_message": None
81
+ }
82
+
83
+ except Exception as e:
84
+ error_msg = f"Erro ao carregar PDF: {str(e)}"
85
+ log_node_execution("PDF_LOADER", "ERROR", error_msg)
86
+ main_logger.exception("Erro detalhado no carregamento do PDF:")
87
+
88
+ return {
89
+ "processing_status": ProcessingStatus.ERROR,
90
+ "error_message": error_msg
91
+ }
92
+
93
+
94
+ def extract_text_from_pdf(pdf_path: str) -> str:
95
+ """
96
+ Extrai texto de um arquivo PDF usando PyPDF2.
97
+
98
+ Args:
99
+ pdf_path: Caminho para o arquivo PDF
100
+
101
+ Returns:
102
+ str: Texto extraído do PDF
103
+
104
+ Raises:
105
+ Exception: Se houver erro na leitura do PDF
106
+ """
107
+ try:
108
+ text_content = []
109
+
110
+ # Abre e lê o PDF
111
+ with open(pdf_path, 'rb') as file:
112
+ pdf_reader = PdfReader(file)
113
+
114
+ # Extrai texto de cada página
115
+ for page_num, page in enumerate(pdf_reader.pages):
116
+ try:
117
+ page_text = page.extract_text()
118
+ if page_text.strip(): # Só adiciona se a página tem texto
119
+ text_content.append(page_text)
120
+ main_logger.debug(f"Texto extraído da página {page_num + 1}")
121
+ except Exception as e:
122
+ main_logger.warning(f"Erro ao extrair texto da página {page_num + 1}: {e}")
123
+ continue
124
+
125
+ # Junta todo o texto
126
+ full_text = "\n\n".join(text_content)
127
+
128
+ # Limpa o texto (remove espaços extras, quebras de linha desnecessárias)
129
+ cleaned_text = clean_extracted_text(full_text)
130
+
131
+ main_logger.info(f"PDF processado: {len(pdf_reader.pages)} páginas, {len(cleaned_text)} caracteres")
132
+
133
+ return cleaned_text
134
+
135
+ except Exception as e:
136
+ main_logger.error(f"Erro ao extrair texto do PDF {pdf_path}: {e}")
137
+ raise
138
+
139
+
140
+ def clean_extracted_text(text: str) -> str:
141
+ """
142
+ Limpa e normaliza o texto extraído do PDF.
143
+
144
+ Args:
145
+ text: Texto bruto extraído do PDF
146
+
147
+ Returns:
148
+ str: Texto limpo e normalizado
149
+ """
150
+ if not text:
151
+ return ""
152
+
153
+ # Remove quebras de linha excessivas
154
+ text = text.replace('\n\n\n', '\n\n')
155
+
156
+ # Remove espaços extras
157
+ lines = []
158
+ for line in text.split('\n'):
159
+ cleaned_line = ' '.join(line.split()) # Remove espaços extras
160
+ if cleaned_line: # Só adiciona linhas não vazias
161
+ lines.append(cleaned_line)
162
+
163
+ # Junta as linhas limpas
164
+ cleaned_text = '\n'.join(lines)
165
+
166
+ return cleaned_text
167
+
168
+
169
+ def validate_pdf_file(pdf_path: str) -> tuple[bool, str]:
170
+ """
171
+ Valida se um arquivo PDF é válido e pode ser processado.
172
+
173
+ Args:
174
+ pdf_path: Caminho para o arquivo PDF
175
+
176
+ Returns:
177
+ tuple[bool, str]: (é_válido, mensagem_de_erro)
178
+ """
179
+ try:
180
+ # Verifica se o arquivo existe
181
+ if not os.path.exists(pdf_path):
182
+ return False, f"Arquivo não encontrado: {pdf_path}"
183
+
184
+ # Verifica se é um arquivo PDF
185
+ if not pdf_path.lower().endswith('.pdf'):
186
+ return False, "Arquivo deve ter extensão .pdf"
187
+
188
+ # Tenta abrir o PDF para verificar se é válido
189
+ with open(pdf_path, 'rb') as file:
190
+ pdf_reader = PdfReader(file)
191
+
192
+ # Verifica se tem pelo menos uma página
193
+ if len(pdf_reader.pages) == 0:
194
+ return False, "PDF não contém páginas"
195
+
196
+ return True, "PDF válido"
197
+
198
+ except Exception as e:
199
+ return False, f"Erro ao validar PDF: {str(e)}"
nodes/text_processor.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nó de processamento de texto para o AgentPDF.
3
+
4
+ Este nó é responsável por dividir o texto extraído do PDF em chunks
5
+ menores usando RecursiveCharacterTextSplitter para otimizar a recuperação.
6
+ """
7
+
8
+ from typing import Dict, Any, List
9
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
10
+ from langchain_core.runnables import RunnableConfig
11
+
12
+ from agents.state import PDFState, ProcessingStatus
13
+ from utils.config import Config
14
+ from utils.logger import log_node_execution, main_logger
15
+
16
+
17
+ def text_processing_node(state: PDFState, config: RunnableConfig) -> Dict[str, Any]:
18
+ """
19
+ Nó responsável por processar e dividir o texto em chunks.
20
+
21
+ Este nó:
22
+ 1. Recebe o texto extraído do PDF
23
+ 2. Divide o texto em chunks usando RecursiveCharacterTextSplitter
24
+ 3. Otimiza os chunks para melhor recuperação
25
+ 4. Atualiza o estado com os chunks processados
26
+
27
+ Args:
28
+ state: Estado atual do grafo contendo o texto do PDF
29
+ config: Configuração do LangGraph
30
+
31
+ Returns:
32
+ Dict[str, Any]: Atualizações para o estado
33
+ """
34
+ log_node_execution("TEXT_PROCESSOR", "START", "Iniciando processamento de texto")
35
+
36
+ try:
37
+ # Verifica se há texto para processar
38
+ pdf_text = state.get("pdf_text")
39
+ if not pdf_text:
40
+ error_msg = "Nenhum texto encontrado para processar"
41
+ log_node_execution("TEXT_PROCESSOR", "ERROR", error_msg)
42
+ return {
43
+ "processing_status": ProcessingStatus.ERROR,
44
+ "error_message": error_msg
45
+ }
46
+
47
+ log_node_execution(
48
+ "TEXT_PROCESSOR",
49
+ "PROCESSING",
50
+ f"Processando texto de {len(pdf_text)} caracteres"
51
+ )
52
+
53
+ # Configura o text splitter
54
+ text_splitter = create_text_splitter()
55
+
56
+ # Divide o texto em chunks
57
+ chunks = text_splitter.split_text(pdf_text)
58
+
59
+ if not chunks:
60
+ error_msg = "Nenhum chunk foi gerado do texto"
61
+ log_node_execution("TEXT_PROCESSOR", "ERROR", error_msg)
62
+ return {
63
+ "processing_status": ProcessingStatus.ERROR,
64
+ "error_message": error_msg
65
+ }
66
+
67
+ # Processa e otimiza os chunks
68
+ processed_chunks = process_chunks(chunks)
69
+
70
+ log_node_execution(
71
+ "TEXT_PROCESSOR",
72
+ "SUCCESS",
73
+ f"Texto dividido em {len(processed_chunks)} chunks"
74
+ )
75
+
76
+ return {
77
+ "pdf_chunks": processed_chunks,
78
+ "processing_status": ProcessingStatus.CREATING_EMBEDDINGS,
79
+ "error_message": None
80
+ }
81
+
82
+ except Exception as e:
83
+ error_msg = f"Erro ao processar texto: {str(e)}"
84
+ log_node_execution("TEXT_PROCESSOR", "ERROR", error_msg)
85
+ main_logger.exception("Erro detalhado no processamento de texto:")
86
+
87
+ return {
88
+ "processing_status": ProcessingStatus.ERROR,
89
+ "error_message": error_msg
90
+ }
91
+
92
+
93
+ def create_text_splitter() -> RecursiveCharacterTextSplitter:
94
+ """
95
+ Cria e configura o RecursiveCharacterTextSplitter.
96
+
97
+ Returns:
98
+ RecursiveCharacterTextSplitter: Splitter configurado
99
+ """
100
+ # Obtém configurações
101
+ config = Config.get_text_splitter_config()
102
+
103
+ # Separadores hierárquicos para melhor divisão
104
+ separators = [
105
+ "\n\n", # Parágrafos
106
+ "\n", # Quebras de linha
107
+ ". ", # Frases
108
+ "! ", # Exclamações
109
+ "? ", # Perguntas
110
+ "; ", # Ponto e vírgula
111
+ ", ", # Vírgulas
112
+ " ", # Espaços
113
+ "" # Caracteres individuais
114
+ ]
115
+
116
+ text_splitter = RecursiveCharacterTextSplitter(
117
+ chunk_size=config["chunk_size"],
118
+ chunk_overlap=config["chunk_overlap"],
119
+ separators=separators,
120
+ length_function=len,
121
+ is_separator_regex=False,
122
+ )
123
+
124
+ main_logger.debug(f"Text splitter configurado: chunk_size={config['chunk_size']}, overlap={config['chunk_overlap']}")
125
+
126
+ return text_splitter
127
+
128
+
129
+ def process_chunks(chunks: List[str]) -> List[str]:
130
+ """
131
+ Processa e otimiza os chunks de texto.
132
+
133
+ Args:
134
+ chunks: Lista de chunks brutos
135
+
136
+ Returns:
137
+ List[str]: Lista de chunks processados e otimizados
138
+ """
139
+ processed_chunks = []
140
+
141
+ for i, chunk in enumerate(chunks):
142
+ # Limpa o chunk
143
+ cleaned_chunk = clean_chunk(chunk)
144
+
145
+ # Só adiciona chunks com conteúdo significativo
146
+ if is_meaningful_chunk(cleaned_chunk):
147
+ processed_chunks.append(cleaned_chunk)
148
+ main_logger.debug(f"Chunk {i+1} processado: {len(cleaned_chunk)} caracteres")
149
+ else:
150
+ main_logger.debug(f"Chunk {i+1} descartado por falta de conteúdo significativo")
151
+
152
+ # Log estatísticas
153
+ main_logger.info(f"Chunks processados: {len(processed_chunks)} de {len(chunks)} originais")
154
+
155
+ if processed_chunks:
156
+ avg_length = sum(len(chunk) for chunk in processed_chunks) / len(processed_chunks)
157
+ main_logger.info(f"Tamanho médio dos chunks: {avg_length:.0f} caracteres")
158
+
159
+ return processed_chunks
160
+
161
+
162
+ def clean_chunk(chunk: str) -> str:
163
+ """
164
+ Limpa e normaliza um chunk de texto.
165
+
166
+ Args:
167
+ chunk: Chunk bruto
168
+
169
+ Returns:
170
+ str: Chunk limpo
171
+ """
172
+ if not chunk:
173
+ return ""
174
+
175
+ # Remove espaços extras no início e fim
176
+ chunk = chunk.strip()
177
+
178
+ # Normaliza quebras de linha
179
+ chunk = chunk.replace('\r\n', '\n').replace('\r', '\n')
180
+
181
+ # Remove quebras de linha excessivas
182
+ while '\n\n\n' in chunk:
183
+ chunk = chunk.replace('\n\n\n', '\n\n')
184
+
185
+ # Remove espaços extras entre palavras
186
+ lines = []
187
+ for line in chunk.split('\n'):
188
+ cleaned_line = ' '.join(line.split())
189
+ if cleaned_line:
190
+ lines.append(cleaned_line)
191
+
192
+ return '\n'.join(lines)
193
+
194
+
195
+ def is_meaningful_chunk(chunk: str) -> bool:
196
+ """
197
+ Verifica se um chunk contém conteúdo significativo.
198
+
199
+ Args:
200
+ chunk: Chunk para verificar
201
+
202
+ Returns:
203
+ bool: True se o chunk é significativo
204
+ """
205
+ if not chunk or len(chunk.strip()) < 50: # Muito pequeno
206
+ return False
207
+
208
+ # Conta palavras
209
+ words = chunk.split()
210
+ if len(words) < 10: # Muito poucas palavras
211
+ return False
212
+
213
+ # Verifica se não é só números ou caracteres especiais
214
+ alpha_chars = sum(1 for c in chunk if c.isalpha())
215
+ if alpha_chars < len(chunk) * 0.5: # Menos de 50% são letras
216
+ return False
217
+
218
+ return True
219
+
220
+
221
+ def get_chunk_statistics(chunks: List[str]) -> Dict[str, Any]:
222
+ """
223
+ Calcula estatísticas dos chunks processados.
224
+
225
+ Args:
226
+ chunks: Lista de chunks
227
+
228
+ Returns:
229
+ Dict[str, Any]: Estatísticas dos chunks
230
+ """
231
+ if not chunks:
232
+ return {
233
+ "total_chunks": 0,
234
+ "total_characters": 0,
235
+ "average_length": 0,
236
+ "min_length": 0,
237
+ "max_length": 0
238
+ }
239
+
240
+ lengths = [len(chunk) for chunk in chunks]
241
+
242
+ return {
243
+ "total_chunks": len(chunks),
244
+ "total_characters": sum(lengths),
245
+ "average_length": sum(lengths) / len(lengths),
246
+ "min_length": min(lengths),
247
+ "max_length": max(lengths)
248
+ }
249
+
250
+
251
+ def optimize_chunks_for_retrieval(chunks: List[str]) -> List[str]:
252
+ """
253
+ Otimiza chunks para melhor performance na recuperação.
254
+
255
+ Args:
256
+ chunks: Lista de chunks originais
257
+
258
+ Returns:
259
+ List[str]: Lista de chunks otimizados
260
+ """
261
+ optimized = []
262
+
263
+ for chunk in chunks:
264
+ # Adiciona contexto se necessário
265
+ if len(chunk) < 200: # Chunks muito pequenos
266
+ # Tenta combinar com o próximo chunk se possível
267
+ continue
268
+
269
+ # Garante que chunks importantes sejam preservados
270
+ if contains_important_content(chunk):
271
+ optimized.append(chunk)
272
+
273
+ return optimized if optimized else chunks # Fallback para chunks originais
274
+
275
+
276
+ def contains_important_content(chunk: str) -> bool:
277
+ """
278
+ Verifica se um chunk contém conteúdo importante.
279
+
280
+ Args:
281
+ chunk: Chunk para verificar
282
+
283
+ Returns:
284
+ bool: True se contém conteúdo importante
285
+ """
286
+ # Palavras-chave que indicam conteúdo importante
287
+ important_keywords = [
288
+ 'definição', 'conceito', 'importante', 'fundamental',
289
+ 'princípio', 'regra', 'lei', 'teoria', 'método',
290
+ 'processo', 'procedimento', 'resultado', 'conclusão'
291
+ ]
292
+
293
+ chunk_lower = chunk.lower()
294
+
295
+ # Verifica presença de palavras-chave importantes
296
+ for keyword in important_keywords:
297
+ if keyword in chunk_lower:
298
+ return True
299
+
300
+ # Verifica se contém listas ou enumerações
301
+ if any(marker in chunk for marker in ['1.', '2.', '•', '-', 'a)', 'b)']):
302
+ return True
303
+
304
+ return True # Por padrão, considera importante
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ langchain==0.3.12
2
+ langchain-community==0.3.12
3
+ langchain-core==0.3.26
4
+ langchain-openai==0.2.14
5
+ langgraph==0.2.60
6
+ gradio==5.9.1
7
+ pypdf2==3.0.1
8
+ faiss-cpu==1.9.0
9
+ python-dotenv==1.0.1
10
+ pydantic==2.10.4
11
+ typing-extensions==4.12.2
12
+ openai==1.58.1
tests/test_basic.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Testes básicos para o AgentPDF.
3
+
4
+ Este módulo contém testes unitários básicos para verificar
5
+ o funcionamento dos componentes principais.
6
+ """
7
+
8
+ import unittest
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ # Adiciona o diretório raiz ao path
14
+ root_dir = Path(__file__).parent.parent
15
+ sys.path.insert(0, str(root_dir))
16
+
17
+ from utils.config import Config
18
+ from utils.logger import setup_logger
19
+ from agents.state import PDFState, ProcessingStatus
20
+
21
+
22
+ class TestConfig(unittest.TestCase):
23
+ """Testes para a configuração."""
24
+
25
+ def test_config_attributes(self):
26
+ """Testa se os atributos de configuração existem."""
27
+ self.assertTrue(hasattr(Config, 'DEFAULT_MODEL'))
28
+ self.assertTrue(hasattr(Config, 'CHUNK_SIZE'))
29
+ self.assertTrue(hasattr(Config, 'TOP_K_DOCUMENTS'))
30
+
31
+ def test_model_config(self):
32
+ """Testa a configuração do modelo."""
33
+ model_config = Config.get_model_config()
34
+ self.assertIn('model', model_config)
35
+ self.assertIn('temperature', model_config)
36
+ self.assertIn('max_tokens', model_config)
37
+
38
+ def test_text_splitter_config(self):
39
+ """Testa a configuração do text splitter."""
40
+ splitter_config = Config.get_text_splitter_config()
41
+ self.assertIn('chunk_size', splitter_config)
42
+ self.assertIn('chunk_overlap', splitter_config)
43
+
44
+
45
+ class TestState(unittest.TestCase):
46
+ """Testes para as estruturas de estado."""
47
+
48
+ def test_processing_status(self):
49
+ """Testa os status de processamento."""
50
+ self.assertEqual(ProcessingStatus.IDLE, "idle")
51
+ self.assertEqual(ProcessingStatus.LOADING_PDF, "loading_pdf")
52
+ self.assertEqual(ProcessingStatus.ERROR, "error")
53
+
54
+ def test_pdf_state_structure(self):
55
+ """Testa a estrutura do PDFState."""
56
+ # Verifica se PDFState é um TypedDict válido
57
+ self.assertTrue(hasattr(PDFState, '__annotations__'))
58
+
59
+ # Verifica se tem os campos essenciais
60
+ annotations = PDFState.__annotations__
61
+ self.assertIn('messages', annotations)
62
+ self.assertIn('pdf_path', annotations)
63
+ self.assertIn('processing_status', annotations)
64
+
65
+
66
+ class TestLogger(unittest.TestCase):
67
+ """Testes para o sistema de logging."""
68
+
69
+ def test_logger_creation(self):
70
+ """Testa a criação do logger."""
71
+ logger = setup_logger("test", "INFO")
72
+ self.assertIsNotNone(logger)
73
+ self.assertEqual(logger.name, "test")
74
+
75
+
76
+ class TestDirectories(unittest.TestCase):
77
+ """Testes para estrutura de diretórios."""
78
+
79
+ def test_required_directories_exist(self):
80
+ """Testa se os diretórios necessários existem."""
81
+ required_dirs = [
82
+ 'agents',
83
+ 'nodes',
84
+ 'utils',
85
+ 'gradio',
86
+ 'uploaded_data'
87
+ ]
88
+
89
+ for dir_name in required_dirs:
90
+ self.assertTrue(
91
+ os.path.exists(dir_name),
92
+ f"Diretório {dir_name} não encontrado"
93
+ )
94
+
95
+
96
+ class TestImports(unittest.TestCase):
97
+ """Testa se todos os módulos podem ser importados."""
98
+
99
+ def test_import_config(self):
100
+ """Testa importação do módulo config."""
101
+ try:
102
+ from utils.config import Config
103
+ self.assertTrue(True)
104
+ except ImportError as e:
105
+ self.fail(f"Erro ao importar config: {e}")
106
+
107
+ def test_import_state(self):
108
+ """Testa importação do módulo state."""
109
+ try:
110
+ from agents.state import PDFState
111
+ self.assertTrue(True)
112
+ except ImportError as e:
113
+ self.fail(f"Erro ao importar state: {e}")
114
+
115
+ def test_import_main_graph(self):
116
+ """Testa importação do grafo principal."""
117
+ try:
118
+ from main_graph import AgentPDFGraph
119
+ self.assertTrue(True)
120
+ except ImportError as e:
121
+ self.fail(f"Erro ao importar main_graph: {e}")
122
+
123
+
124
+ if __name__ == '__main__':
125
+ # Configura logging para testes
126
+ setup_logger("AgentPDF.Tests", "WARNING")
127
+
128
+ # Executa os testes
129
+ unittest.main(verbosity=2)
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Módulo de utilitários
utils/config.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configurações e utilitários para o AgentPDF.
3
+
4
+ Este módulo contém configurações globais, carregamento de variáveis
5
+ de ambiente e funções utilitárias para o projeto.
6
+ """
7
+
8
+ import os
9
+ from dotenv import load_dotenv
10
+ from typing import Optional
11
+
12
+ # Carrega variáveis de ambiente
13
+ load_dotenv()
14
+
15
+
16
+ class Config:
17
+ """Classe de configuração centralizada."""
18
+
19
+ # API Keys
20
+ OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
21
+ LANGCHAIN_API_KEY: str = os.getenv("LANGCHAIN_API_KEY", "")
22
+
23
+ # Configurações do LangChain
24
+ LANGCHAIN_TRACING_V2: bool = os.getenv("LANGCHAIN_TRACING_V2", "false").lower() == "true"
25
+ LANGCHAIN_PROJECT: str = os.getenv("LANGCHAIN_PROJECT", "agentpdf")
26
+
27
+ # Configurações do modelo
28
+ DEFAULT_MODEL: str = "gpt-4o-mini"
29
+ DEFAULT_TEMPERATURE: float = 0.1
30
+ MAX_TOKENS: int = 2000
31
+
32
+ # Configurações de processamento de texto
33
+ CHUNK_SIZE: int = 1000
34
+ CHUNK_OVERLAP: int = 200
35
+
36
+ # Configurações de recuperação
37
+ TOP_K_DOCUMENTS: int = 5
38
+ SIMILARITY_THRESHOLD: float = 0.7
39
+
40
+ # Configurações da interface
41
+ GRADIO_PORT: int = 7860
42
+ GRADIO_SHARE: bool = False
43
+
44
+ # Diretórios
45
+ UPLOAD_DIR: str = "uploaded_data"
46
+ TEMP_DIR: str = "temp"
47
+
48
+ @classmethod
49
+ def validate_config(cls) -> bool:
50
+ """
51
+ Valida se as configurações essenciais estão presentes.
52
+
53
+ Returns:
54
+ bool: True se a configuração é válida, False caso contrário.
55
+ """
56
+ if not cls.OPENAI_API_KEY:
57
+ print("⚠️ AVISO: OPENAI_API_KEY não configurada!")
58
+ return False
59
+ return True
60
+
61
+ @classmethod
62
+ def get_model_config(cls) -> dict:
63
+ """
64
+ Retorna configurações do modelo LLM.
65
+
66
+ Returns:
67
+ dict: Configurações do modelo.
68
+ """
69
+ return {
70
+ "model": cls.DEFAULT_MODEL,
71
+ "temperature": cls.DEFAULT_TEMPERATURE,
72
+ "max_tokens": cls.MAX_TOKENS,
73
+ }
74
+
75
+ @classmethod
76
+ def get_text_splitter_config(cls) -> dict:
77
+ """
78
+ Retorna configurações do divisor de texto.
79
+
80
+ Returns:
81
+ dict: Configurações do text splitter.
82
+ """
83
+ return {
84
+ "chunk_size": cls.CHUNK_SIZE,
85
+ "chunk_overlap": cls.CHUNK_OVERLAP,
86
+ }
87
+
88
+ @classmethod
89
+ def get_retrieval_config(cls) -> dict:
90
+ """
91
+ Retorna configurações de recuperação.
92
+
93
+ Returns:
94
+ dict: Configurações de recuperação.
95
+ """
96
+ return {
97
+ "k": cls.TOP_K_DOCUMENTS,
98
+ "score_threshold": cls.SIMILARITY_THRESHOLD,
99
+ }
100
+
101
+
102
+ def ensure_directories():
103
+ """Garante que os diretórios necessários existam."""
104
+ directories = [Config.UPLOAD_DIR, Config.TEMP_DIR]
105
+ for directory in directories:
106
+ os.makedirs(directory, exist_ok=True)
107
+
108
+
109
+ def get_openai_api_key() -> Optional[str]:
110
+ """
111
+ Retorna a chave da API OpenAI.
112
+
113
+ Returns:
114
+ Optional[str]: Chave da API ou None se não configurada.
115
+ """
116
+ return Config.OPENAI_API_KEY if Config.OPENAI_API_KEY else None
117
+
118
+
119
+ # Inicialização
120
+ ensure_directories()
utils/logger.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sistema de logging para o AgentPDF.
3
+
4
+ Fornece logging estruturado e colorido para melhor debugging
5
+ e monitoramento do sistema.
6
+ """
7
+
8
+ import logging
9
+ import sys
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+
14
+ class ColoredFormatter(logging.Formatter):
15
+ """Formatter personalizado com cores para diferentes níveis de log."""
16
+
17
+ # Códigos de cores ANSI
18
+ COLORS = {
19
+ 'DEBUG': '\033[36m', # Ciano
20
+ 'INFO': '\033[32m', # Verde
21
+ 'WARNING': '\033[33m', # Amarelo
22
+ 'ERROR': '\033[31m', # Vermelho
23
+ 'CRITICAL': '\033[35m', # Magenta
24
+ 'RESET': '\033[0m' # Reset
25
+ }
26
+
27
+ def format(self, record):
28
+ # Adiciona cor baseada no nível
29
+ color = self.COLORS.get(record.levelname, self.COLORS['RESET'])
30
+ reset = self.COLORS['RESET']
31
+
32
+ # Formato personalizado
33
+ record.levelname = f"{color}{record.levelname}{reset}"
34
+
35
+ return super().format(record)
36
+
37
+
38
+ def setup_logger(name: str = "AgentPDF", level: str = "INFO") -> logging.Logger:
39
+ """
40
+ Configura e retorna um logger personalizado.
41
+
42
+ Args:
43
+ name: Nome do logger
44
+ level: Nível de logging (DEBUG, INFO, WARNING, ERROR, CRITICAL)
45
+
46
+ Returns:
47
+ logging.Logger: Logger configurado
48
+ """
49
+ logger = logging.getLogger(name)
50
+
51
+ # Evita duplicação de handlers
52
+ if logger.handlers:
53
+ return logger
54
+
55
+ # Configura nível
56
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
57
+ logger.setLevel(numeric_level)
58
+
59
+ # Handler para console
60
+ console_handler = logging.StreamHandler(sys.stdout)
61
+ console_handler.setLevel(numeric_level)
62
+
63
+ # Formatter com cores
64
+ formatter = ColoredFormatter(
65
+ '%(asctime)s | %(levelname)s | %(name)s | %(message)s',
66
+ datefmt='%H:%M:%S'
67
+ )
68
+ console_handler.setFormatter(formatter)
69
+
70
+ logger.addHandler(console_handler)
71
+
72
+ return logger
73
+
74
+
75
+ def log_node_execution(node_name: str, status: str, details: Optional[str] = None):
76
+ """
77
+ Log específico para execução de nós do LangGraph.
78
+
79
+ Args:
80
+ node_name: Nome do nó
81
+ status: Status da execução (START, SUCCESS, ERROR)
82
+ details: Detalhes adicionais
83
+ """
84
+ logger = logging.getLogger("AgentPDF.Nodes")
85
+
86
+ emoji_map = {
87
+ "START": "🚀",
88
+ "SUCCESS": "✅",
89
+ "ERROR": "❌",
90
+ "PROCESSING": "⚙️"
91
+ }
92
+
93
+ emoji = emoji_map.get(status, "📝")
94
+ message = f"{emoji} {node_name} - {status}"
95
+
96
+ if details:
97
+ message += f" | {details}"
98
+
99
+ if status == "ERROR":
100
+ logger.error(message)
101
+ elif status == "START" or status == "PROCESSING":
102
+ logger.info(message)
103
+ else:
104
+ logger.info(message)
105
+
106
+
107
+ def log_graph_execution(action: str, details: Optional[str] = None):
108
+ """
109
+ Log específico para execução do grafo principal.
110
+
111
+ Args:
112
+ action: Ação sendo executada
113
+ details: Detalhes adicionais
114
+ """
115
+ logger = logging.getLogger("AgentPDF.Graph")
116
+
117
+ message = f"🔄 {action}"
118
+ if details:
119
+ message += f" | {details}"
120
+
121
+ logger.info(message)
122
+
123
+
124
+ # Logger principal do sistema
125
+ main_logger = setup_logger("AgentPDF", "INFO")