Deploy
Browse files- .gitignore +58 -0
- agents/__init__.py +1 -0
- agents/state.py +108 -0
- app.py +94 -0
- interface/__init__.py +1 -0
- interface/modern_interface.py +255 -0
- main_graph.py +332 -0
- nodes/__init__.py +1 -0
- nodes/context_retriever.py +333 -0
- nodes/embeddings_creator.py +303 -0
- nodes/llm_agent.py +322 -0
- nodes/pdf_loader.py +199 -0
- nodes/text_processor.py +304 -0
- requirements.txt +12 -0
- tests/test_basic.py +129 -0
- utils/__init__.py +1 -0
- utils/config.py +120 -0
- utils/logger.py +125 -0
.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")
|