|
|
import os |
|
|
import re |
|
|
import json |
|
|
import sys |
|
|
import pdfplumber |
|
|
import requests |
|
|
import tkinter as tk |
|
|
from tkinter import scrolledtext, messagebox, simpledialog |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if getattr(sys, "frozen", False): |
|
|
BASE_DIR = os.path.dirname(os.path.abspath(sys.argv[0])) |
|
|
else: |
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
|
|
|
|
|
DOCS_DIR = os.path.join(BASE_DIR, "materiais") |
|
|
KNOWLEDGE_PATH = os.path.join(BASE_DIR, "knowledge_base.json") |
|
|
|
|
|
MAX_CONTEXT_CHARS = 6000 |
|
|
|
|
|
LM_STUDIO_URL = "http://127.0.0.1:1234/v1/chat/completions" |
|
|
LM_API_KEY = "lm-studio" |
|
|
MODEL_NAME = "meta-llama-3.1-8b-instruct" |
|
|
|
|
|
|
|
|
KB = [] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_text_from_pdf(pdf_path: str) -> str: |
|
|
text = "" |
|
|
with pdfplumber.open(pdf_path) as pdf: |
|
|
for page in pdf.pages: |
|
|
page_text = page.extract_text() |
|
|
if page_text: |
|
|
text += page_text + "\n" |
|
|
return text |
|
|
|
|
|
|
|
|
def build_knowledge_base(): |
|
|
""" |
|
|
Lê todos os PDFs em DOCS_DIR e salva knowledge_base.json. |
|
|
Retorna a lista de parágrafos (kb). |
|
|
""" |
|
|
kb = [] |
|
|
|
|
|
if not os.path.isdir(DOCS_DIR): |
|
|
os.makedirs(DOCS_DIR, exist_ok=True) |
|
|
|
|
|
pdf_files = [f for f in os.listdir(DOCS_DIR) if f.lower().endswith(".pdf")] |
|
|
|
|
|
if not pdf_files: |
|
|
return [] |
|
|
|
|
|
for filename in pdf_files: |
|
|
full_path = os.path.join(DOCS_DIR, filename) |
|
|
raw_text = extract_text_from_pdf(full_path) |
|
|
|
|
|
paragraphs = re.split(r"\n\s*\n", raw_text) |
|
|
for p in paragraphs: |
|
|
p_clean = p.strip() |
|
|
if not p_clean: |
|
|
continue |
|
|
kb.append({"source": filename, "text": p_clean}) |
|
|
|
|
|
with open(KNOWLEDGE_PATH, "w", encoding="utf-8") as f: |
|
|
json.dump(kb, f, ensure_ascii=False, indent=2) |
|
|
|
|
|
return kb |
|
|
|
|
|
|
|
|
def load_knowledge_base(): |
|
|
if not os.path.exists(KNOWLEDGE_PATH): |
|
|
return [] |
|
|
with open(KNOWLEDGE_PATH, "r", encoding="utf-8") as f: |
|
|
kb = json.load(f) |
|
|
return kb |
|
|
|
|
|
|
|
|
def search_paragraphs(question: str, kb, top_k: int = 5): |
|
|
tokens = re.findall(r"\w+", question.lower()) |
|
|
tokens = [t for t in tokens if len(t) > 2] |
|
|
|
|
|
scored = [] |
|
|
for item in kb: |
|
|
text_lower = item["text"].lower() |
|
|
score = sum(text_lower.count(tok) for tok in tokens) |
|
|
if score > 0: |
|
|
scored.append((score, item)) |
|
|
|
|
|
scored.sort(key=lambda x: x[0], reverse=True) |
|
|
top_items = [item for score, item in scored[:top_k]] |
|
|
return top_items |
|
|
|
|
|
|
|
|
def build_context_string(chunks): |
|
|
parts = [] |
|
|
for c in chunks: |
|
|
parts.append(f"[Fonte: {c['source']}]\n{c['text']}") |
|
|
context = "\n\n".join(parts) |
|
|
if len(context) > MAX_CONTEXT_CHARS: |
|
|
context = context[:MAX_CONTEXT_CHARS] |
|
|
return context |
|
|
|
|
|
|
|
|
def call_llm(system_prompt: str, user_prompt: str) -> str: |
|
|
payload = { |
|
|
"model": MODEL_NAME, |
|
|
"messages": [ |
|
|
{"role": "system", "content": system_prompt}, |
|
|
{"role": "user", "content": user_prompt}, |
|
|
], |
|
|
"temperature": 0.2, |
|
|
} |
|
|
|
|
|
resp = requests.post( |
|
|
LM_STUDIO_URL, |
|
|
headers={ |
|
|
"Content-Type": "application/json", |
|
|
"Authorization": f"Bearer {LM_API_KEY}" |
|
|
}, |
|
|
json=payload, |
|
|
timeout=600, |
|
|
) |
|
|
resp.raise_for_status() |
|
|
data = resp.json() |
|
|
return data["choices"][0]["message"]["content"].strip() |
|
|
|
|
|
|
|
|
def resumo_geral(kb): |
|
|
chunks = kb[:8] if len(kb) > 8 else kb |
|
|
context = build_context_string(chunks) |
|
|
|
|
|
system_prompt = ( |
|
|
"Você é um assistente de estudos. " |
|
|
"Você recebe trechos de materiais em CONTEXTO e deve gerar um resumo didático em português, " |
|
|
"organizando os principais tópicos e ideias de forma clara e objetiva. " |
|
|
"Não invente informações fora do contexto." |
|
|
) |
|
|
|
|
|
user_prompt = ( |
|
|
"Use o CONTEXTO a seguir para gerar um resumo geral dos principais pontos, " |
|
|
"organizado em tópicos, com frases curtas e linguagem simples.\n\n" |
|
|
f"CONTEXTO:\n{context}" |
|
|
) |
|
|
|
|
|
return call_llm(system_prompt, user_prompt) |
|
|
|
|
|
|
|
|
def pontos_chave(kb): |
|
|
chunks = kb[:8] if len(kb) > 8 else kb |
|
|
context = build_context_string(chunks) |
|
|
|
|
|
system_prompt = ( |
|
|
"Você é um assistente de estudos. " |
|
|
"Você recebe trechos de materiais em CONTEXTO e deve listar os pontos chave em português, " |
|
|
"como se fossem itens de revisão rápida para prova." |
|
|
) |
|
|
|
|
|
user_prompt = ( |
|
|
"Use o CONTEXTO a seguir para listar os principais pontos que a pessoa precisa lembrar, " |
|
|
"em formato de tópicos. Foque em conceitos importantes, definições e ideias centrais.\n\n" |
|
|
f"CONTEXTO:\n{context}" |
|
|
) |
|
|
|
|
|
return call_llm(system_prompt, user_prompt) |
|
|
|
|
|
|
|
|
def perguntas_estudo(kb, tema: str = "", n_questoes: int = 10): |
|
|
if tema: |
|
|
chunks = search_paragraphs(tema, kb, top_k=10) |
|
|
if not chunks: |
|
|
chunks = kb[:20] if len(kb) > 20 else kb |
|
|
else: |
|
|
chunks = kb[:20] if len(kb) > 20 else kb |
|
|
|
|
|
context = build_context_string(chunks) |
|
|
|
|
|
system_prompt = ( |
|
|
"Você é um professor ajudando um estudante a revisar o conteúdo. " |
|
|
"Você recebe trechos de materiais em CONTEXTO e deve gerar perguntas de estudo em português. " |
|
|
"Não inclua as respostas, apenas as perguntas." |
|
|
) |
|
|
|
|
|
user_prompt = ( |
|
|
f"Use o CONTEXTO abaixo para criar aproximadamente {n_questoes} perguntas de estudo. " |
|
|
"Misture perguntas de definição, compreensão e comparação, mas sempre baseadas apenas no contexto.\n\n" |
|
|
f"CONTEXTO:\n{context}" |
|
|
) |
|
|
|
|
|
return call_llm(system_prompt, user_prompt) |
|
|
|
|
|
|
|
|
def responder_chat(kb, pergunta: str) -> str: |
|
|
chunks = search_paragraphs(pergunta, kb, top_k=3) |
|
|
if not chunks: |
|
|
return "Não encontrei essa informação nos materiais carregados." |
|
|
|
|
|
context = build_context_string(chunks) |
|
|
|
|
|
system_prompt = ( |
|
|
"Você é um assistente de estudos. " |
|
|
"Responda em português de forma clara, objetiva e didática, usando apenas o que está no CONTEXTO. " |
|
|
"Se a resposta não estiver no contexto, diga apenas: " |
|
|
"'Não encontrei essa informação nos materiais carregados.' " |
|
|
"Não invente informações e não use conhecimento externo." |
|
|
) |
|
|
|
|
|
user_prompt = f"CONTEXTO:\n{context}\n\nPERGUNTA:\n{pergunta}" |
|
|
return call_llm(system_prompt, user_prompt) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RAGStudyChatApp: |
|
|
def __init__(self, root): |
|
|
self.root = root |
|
|
self.root.title("RAGStudy - Assistente de Estudos Offline") |
|
|
self.root.geometry("950x620") |
|
|
self.root.configure(bg="#202123") |
|
|
|
|
|
|
|
|
top_frame = tk.Frame(root, bg="#202123") |
|
|
top_frame.pack(fill="x", padx=10, pady=(10, 5)) |
|
|
|
|
|
title_label = tk.Label( |
|
|
top_frame, |
|
|
text="RAGStudy", |
|
|
font=("Segoe UI", 13, "bold"), |
|
|
bg="#202123", |
|
|
fg="white" |
|
|
) |
|
|
title_label.pack(side="left") |
|
|
|
|
|
self.status_label = tk.Label( |
|
|
top_frame, |
|
|
text="Base não carregada.", |
|
|
bg="#202123", |
|
|
fg="#d1d5db", |
|
|
anchor="w" |
|
|
) |
|
|
self.status_label.pack(side="left", padx=15) |
|
|
|
|
|
self.btn_recarregar = tk.Button( |
|
|
top_frame, |
|
|
text="Recarregar PDFs", |
|
|
command=self.on_recarregar, |
|
|
bg="#10a37f", |
|
|
fg="white", |
|
|
relief="flat", |
|
|
padx=10, |
|
|
pady=3 |
|
|
) |
|
|
self.btn_recarregar.pack(side="right") |
|
|
|
|
|
|
|
|
path_frame = tk.Frame(root, bg="#202123") |
|
|
path_frame.pack(fill="x", padx=10, pady=(0, 8)) |
|
|
path_label = tk.Label( |
|
|
path_frame, |
|
|
text=f"Pasta de materiais: {DOCS_DIR}", |
|
|
bg="#202123", |
|
|
fg="#9ca3af", |
|
|
anchor="w", |
|
|
justify="left" |
|
|
) |
|
|
path_label.pack(fill="x") |
|
|
|
|
|
|
|
|
shortcuts_frame = tk.Frame(root, bg="#202123") |
|
|
shortcuts_frame.pack(fill="x", padx=10, pady=(0, 5)) |
|
|
|
|
|
tk.Label( |
|
|
shortcuts_frame, |
|
|
text="Atalhos:", |
|
|
bg="#202123", |
|
|
fg="#d1d5db" |
|
|
).pack(side="left", padx=(0, 5)) |
|
|
|
|
|
self.btn_resumo = tk.Button( |
|
|
shortcuts_frame, |
|
|
text="Resumo geral", |
|
|
command=self.on_resumo, |
|
|
bg="#3a3b44", |
|
|
fg="white", |
|
|
relief="flat", |
|
|
padx=8 |
|
|
) |
|
|
self.btn_resumo.pack(side="left", padx=3) |
|
|
|
|
|
self.btn_pontos = tk.Button( |
|
|
shortcuts_frame, |
|
|
text="Pontos chave", |
|
|
command=self.on_pontos, |
|
|
bg="#3a3b44", |
|
|
fg="white", |
|
|
relief="flat", |
|
|
padx=8 |
|
|
) |
|
|
self.btn_pontos.pack(side="left", padx=3) |
|
|
|
|
|
self.btn_perguntas = tk.Button( |
|
|
shortcuts_frame, |
|
|
text="Perguntas de estudo", |
|
|
command=self.on_perguntas, |
|
|
bg="#3a3b44", |
|
|
fg="white", |
|
|
relief="flat", |
|
|
padx=8 |
|
|
) |
|
|
self.btn_perguntas.pack(side="left", padx=3) |
|
|
|
|
|
|
|
|
chat_frame = tk.Frame(root, bg="#202123") |
|
|
chat_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5)) |
|
|
|
|
|
self.chat_box = scrolledtext.ScrolledText( |
|
|
chat_frame, |
|
|
wrap="word", |
|
|
bg="#343541", |
|
|
fg="white", |
|
|
insertbackground="white", |
|
|
bd=0, |
|
|
padx=10, |
|
|
pady=10 |
|
|
) |
|
|
self.chat_box.pack(fill="both", expand=True) |
|
|
|
|
|
|
|
|
self.chat_box.tag_configure( |
|
|
"user_name", |
|
|
foreground="#10a37f", |
|
|
font=("Segoe UI", 9, "bold") |
|
|
) |
|
|
self.chat_box.tag_configure( |
|
|
"assistant_name", |
|
|
foreground="#f97316", |
|
|
font=("Segoe UI", 9, "bold") |
|
|
) |
|
|
self.chat_box.tag_configure( |
|
|
"user_msg", |
|
|
background="#444654", |
|
|
foreground="white", |
|
|
lmargin1=15, |
|
|
lmargin2=15, |
|
|
rmargin=50, |
|
|
spacing3=8 |
|
|
) |
|
|
self.chat_box.tag_configure( |
|
|
"assistant_msg", |
|
|
background="#343541", |
|
|
foreground="white", |
|
|
lmargin1=15, |
|
|
lmargin2=15, |
|
|
rmargin=50, |
|
|
spacing3=12 |
|
|
) |
|
|
|
|
|
self.chat_box.configure(state="disabled") |
|
|
|
|
|
|
|
|
input_frame = tk.Frame(root, bg="#202123") |
|
|
input_frame.pack(fill="x", padx=10, pady=(0, 10)) |
|
|
|
|
|
self.entry = tk.Entry( |
|
|
input_frame, |
|
|
bg="#40414f", |
|
|
fg="white", |
|
|
insertbackground="white", |
|
|
relief="flat" |
|
|
) |
|
|
self.entry.pack(side="left", fill="x", expand=True, padx=(0, 8), ipady=6) |
|
|
self.entry.bind("<Return>", self.on_send) |
|
|
|
|
|
send_btn = tk.Button( |
|
|
input_frame, |
|
|
text="Enviar", |
|
|
command=self.on_send, |
|
|
bg="#10a37f", |
|
|
fg="white", |
|
|
relief="flat", |
|
|
padx=12, |
|
|
pady=4 |
|
|
) |
|
|
send_btn.pack(side="right") |
|
|
|
|
|
|
|
|
self.log_assistant( |
|
|
"Olá! Eu sou o RAGStudy, seu assistente de estudos offline.\n\n" |
|
|
"- Coloque PDFs na pasta 'materiais'.\n" |
|
|
"- Clique em 'Recarregar PDFs' para atualizar a base.\n" |
|
|
"- Use os atalhos acima para resumo, pontos chave e perguntas.\n" |
|
|
"- Ou digite qualquer pergunta no campo abaixo." |
|
|
) |
|
|
|
|
|
|
|
|
self.load_initial_kb() |
|
|
|
|
|
|
|
|
|
|
|
def log_user(self, text: str): |
|
|
self.chat_box.configure(state="normal") |
|
|
self.chat_box.insert("end", "Você\n", "user_name") |
|
|
self.chat_box.insert("end", text + "\n\n", "user_msg") |
|
|
self.chat_box.configure(state="disabled") |
|
|
self.chat_box.see("end") |
|
|
|
|
|
def log_assistant(self, text: str): |
|
|
self.chat_box.configure(state="normal") |
|
|
self.chat_box.insert("end", "Assistente\n", "assistant_name") |
|
|
self.chat_box.insert("end", text + "\n\n", "assistant_msg") |
|
|
self.chat_box.configure(state="disabled") |
|
|
self.chat_box.see("end") |
|
|
|
|
|
def set_status(self, text: str): |
|
|
self.status_label.config(text=text) |
|
|
|
|
|
|
|
|
|
|
|
def load_initial_kb(self): |
|
|
global KB |
|
|
kb = load_knowledge_base() |
|
|
if kb: |
|
|
KB = kb |
|
|
self.set_status(f"Base carregada com {len(KB)} parágrafos.") |
|
|
self.log_assistant( |
|
|
"Base carregada a partir do arquivo existente.\n" |
|
|
"Se quiser atualizar com novos PDFs, clique em 'Recarregar PDFs'." |
|
|
) |
|
|
|
|
|
def on_recarregar(self): |
|
|
global KB |
|
|
|
|
|
if not os.path.isdir(DOCS_DIR): |
|
|
os.makedirs(DOCS_DIR, exist_ok=True) |
|
|
|
|
|
pdf_files = [f for f in os.listdir(DOCS_DIR) if f.lower().endswith(".pdf")] |
|
|
|
|
|
if not pdf_files: |
|
|
msg = ( |
|
|
"Nenhum PDF encontrado na pasta:\n" |
|
|
f"{DOCS_DIR}\n\n" |
|
|
"Coloque os arquivos .pdf do assunto que você quer estudar nessa pasta\n" |
|
|
"e clique em 'Recarregar PDFs' novamente." |
|
|
) |
|
|
self.set_status("Nenhum PDF encontrado.") |
|
|
self.log_assistant(msg) |
|
|
return |
|
|
|
|
|
self.set_status("Reconstruindo base a partir dos PDFs...") |
|
|
self.log_assistant("Reconstruindo base a partir dos PDFs... Isso pode levar alguns instantes.") |
|
|
|
|
|
try: |
|
|
kb = build_knowledge_base() |
|
|
KB = kb |
|
|
self.set_status(f"Base carregada com {len(KB)} parágrafos.") |
|
|
self.log_assistant(f"Base criada com {len(KB)} parágrafos a partir de {len(pdf_files)} PDF(s).") |
|
|
except Exception as e: |
|
|
messagebox.showerror("Erro", f"Erro ao reconstruir base: {e}") |
|
|
self.log_assistant(f"Erro ao reconstruir base: {e}") |
|
|
|
|
|
def ensure_kb(self) -> bool: |
|
|
global KB |
|
|
if not KB: |
|
|
messagebox.showwarning( |
|
|
"Base vazia", |
|
|
"Nenhum conteúdo carregado.\n\n" |
|
|
"Coloque PDFs na pasta 'materiais' e clique em 'Recarregar PDFs'." |
|
|
) |
|
|
return False |
|
|
return True |
|
|
|
|
|
|
|
|
|
|
|
def on_resumo(self): |
|
|
if not self.ensure_kb(): |
|
|
return |
|
|
self.log_user("/resumo geral") |
|
|
self.log_assistant("Gerando resumo geral... (isso pode demorar um pouco)") |
|
|
try: |
|
|
texto = resumo_geral(KB) |
|
|
self.log_assistant(texto) |
|
|
except Exception as e: |
|
|
messagebox.showerror("Erro", f"Erro ao gerar resumo: {e}") |
|
|
self.log_assistant(f"Erro ao gerar resumo: {e}") |
|
|
|
|
|
def on_pontos(self): |
|
|
if not self.ensure_kb(): |
|
|
return |
|
|
self.log_user("/pontos chave") |
|
|
self.log_assistant("Gerando pontos chave... (isso pode demorar um pouco)") |
|
|
try: |
|
|
texto = pontos_chave(KB) |
|
|
self.log_assistant(texto) |
|
|
except Exception as e: |
|
|
messagebox.showerror("Erro", f"Erro ao gerar pontos chave: {e}") |
|
|
self.log_assistant(f"Erro ao gerar pontos chave: {e}") |
|
|
|
|
|
def on_perguntas(self): |
|
|
if not self.ensure_kb(): |
|
|
return |
|
|
|
|
|
tema = simpledialog.askstring( |
|
|
"Perguntas de estudo", |
|
|
"Tema/dúvida para gerar perguntas (deixe vazio para usar o conteúdo geral):" |
|
|
) |
|
|
if tema is None: |
|
|
return |
|
|
|
|
|
if tema.strip(): |
|
|
self.log_user(f"/perguntas de estudo sobre: {tema}") |
|
|
else: |
|
|
self.log_user("/perguntas de estudo (geral)") |
|
|
|
|
|
self.log_assistant("Gerando perguntas de estudo... (isso pode demorar um pouco)") |
|
|
try: |
|
|
texto = perguntas_estudo(KB, tema=tema or "", n_questoes=10) |
|
|
self.log_assistant(texto) |
|
|
except Exception as e: |
|
|
messagebox.showerror("Erro", f"Erro ao gerar perguntas: {e}") |
|
|
self.log_assistant(f"Erro ao gerar perguntas: {e}") |
|
|
|
|
|
|
|
|
|
|
|
def on_send(self, event=None): |
|
|
if not self.ensure_kb(): |
|
|
return |
|
|
pergunta = self.entry.get().strip() |
|
|
if not pergunta: |
|
|
return |
|
|
self.entry.delete(0, tk.END) |
|
|
|
|
|
self.log_user(pergunta) |
|
|
self.log_assistant("Gerando resposta... (aguarde)") |
|
|
|
|
|
try: |
|
|
resposta = responder_chat(KB, pergunta) |
|
|
self.log_assistant(resposta) |
|
|
except Exception as e: |
|
|
messagebox.showerror("Erro", f"Erro no chat: {e}") |
|
|
self.log_assistant(f"Erro no chat: {e}") |
|
|
|
|
|
|
|
|
def main(): |
|
|
root = tk.Tk() |
|
|
app = RAGStudyChatApp(root) |
|
|
root.mainloop() |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
main() |
|
|
|