from dotenv import load_dotenv from openai import OpenAI import json import os import requests from pypdf import PdfReader import gradio as gr from openai import APIConnectionError, APITimeoutError, RateLimitError, APIError import time from pathlib import Path import numpy as np import faiss from datetime import datetime from zoneinfo import ZoneInfo load_dotenv(override=True) LOG_PATH = Path("me/questions_log.jsonl") MADRID_TZ = ZoneInfo("Europe/Madrid") def push(text): try: requests.post( "https://api.pushover.net/1/messages.json", data={ "token": os.getenv("PUSHOVER_TOKEN"), "user": os.getenv("PUSHOVER_USER"), "message": text, }, timeout=10, # ✅ evita cuelgues ) except Exception as e: print("Pushover error:", repr(e), flush=True) def log_user_question(question: str, user_label: str = "unknown", notes: str = ""): """ Guarda en JSONL (append) un registro por pregunta. - question: texto literal de la pregunta del usuario - user_label: nombre/email/alias si el LLM lo conoce; si no, "unknown" - notes: pistas extra (ej: "no dio nombre", "dice que es recruiter", etc.) """ LOG_PATH.parent.mkdir(parents=True, exist_ok=True) record = { "ts": datetime.now(MADRID_TZ).isoformat(timespec="seconds"), "user": user_label or "unknown", "question": question, "notes": notes or "", } with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") return {"logged": "ok"} def _read_pdf(path: str) -> str: reader = PdfReader(path) out = [] for page in reader.pages: t = page.extract_text() if t: out.append(t) return "\n".join(out) def _read_text(path: str) -> str: return Path(path).read_text(encoding="utf-8", errors="ignore") def _chunk_text(text: str, chunk_size: int = 1400, overlap: int = 220) -> list[str]: text = " ".join(text.split()) chunks = [] i = 0 while i < len(text): chunks.append(text[i:i + chunk_size]) i += max(1, chunk_size - overlap) return chunks class SimpleRAG: def __init__(self, client, docs_dir="me", embed_model="text-embedding-3-small"): self.client = client self.docs_dir = docs_dir self.embed_model = embed_model self.index = None self.chunks: list[str] = [] def _embed(self, texts: list[str]) -> np.ndarray: resp = self.client.embeddings.create(model=self.embed_model, input=texts) vecs = np.array([d.embedding for d in resp.data], dtype="float32") faiss.normalize_L2(vecs) # cosine via inner product return vecs def build(self): all_chunks = [] base = Path(self.docs_dir) if not base.exists(): print(f"[RAG] docs_dir not found: {self.docs_dir}", flush=True) self.index = None self.chunks = [] return for p in base.rglob("*"): if p.is_dir(): continue suffix = p.suffix.lower() if suffix == ".pdf": raw = _read_pdf(str(p)) elif suffix in [".txt", ".md"]: raw = _read_text(str(p)) else: continue if not raw.strip(): continue for c in _chunk_text(raw): all_chunks.append(f"[{p.name}] {c}") self.chunks = all_chunks if not self.chunks: print("[RAG] No chunks created (empty index).", flush=True) self.index = None return vecs = self._embed(self.chunks) dim = vecs.shape[1] self.index = faiss.IndexFlatIP(dim) self.index.add(vecs) print(f"[RAG] Indexed {len(self.chunks)} chunks from {self.docs_dir}", flush=True) def retrieve(self, query: str, k: int = 4) -> list[str]: if self.index is None or not self.chunks: return [] qv = self._embed([query]) scores, idx = self.index.search(qv, k) out = [] for i, s in zip(idx[0], scores[0]): if i == -1: continue # filtro suave por relevancia (ajusta si quieres) if s < 0.20: continue out.append(self.chunks[int(i)]) return out def record_user_details(email, name="Nombre no indicado", notes="no proporcionadas"): push(f"Registrando {name} con email {email} y notas {notes}") return {"recorded": "ok"} def record_unknown_question(question): push(f"Registrando {question}") return {"recorded": "ok"} record_user_details_json = { "name": "record_user_details", "description": "Utiliza esta herramienta para registrar que un usuario está interesado en estar en contacto y proporcionó una dirección de correo electrónico.", "parameters": { "type": "object", "properties": { "email": { "type": "string", "description": "La dirección de email del usuario" }, "name": { "type": "string", "description": "El nombre del usuario, si se indica" } , "notes": { "type": "string", "description": "¿Alguna información adicional sobre la conversación que valga la pena registrar para dar contexto?" } }, "required": ["email"], "additionalProperties": False } } record_unknown_question_json = { "name": "record_unknown_question", "description": "Utiliza siempre esta herramienta para registrar cualquier pregunta que no haya podido responder porque no se sabía la respuesta.", "parameters": { "type": "object", "properties": { "question": { "type": "string", "description": "La pregunta no sabe responderse" }, }, "required": ["question"], "additionalProperties": False } } log_user_question_json = { "name": "log_user_question", "description": "Registra SIEMPRE la pregunta del usuario en un fichero para análisis posterior. Usa user_label si conoces nombre/email; si no, 'unknown'.", "parameters": { "type": "object", "properties": { "question": {"type": "string", "description": "Pregunta del usuario (texto literal)."}, "user_label": {"type": "string", "description": "Nombre/email/alias si se conoce; si no, 'unknown'."}, "notes": {"type": "string", "description": "Pistas adicionales si no se conoce identidad (ej: 'no dio nombre')."} }, "required": ["question"], "additionalProperties": False } } tools = [{"type": "function", "function": record_user_details_json}, {"type": "function", "function": record_unknown_question_json}, {"type": "function", "function": log_user_question_json}] class Me: def __init__(self): api_key = os.getenv("OPENAI_API_KEY") print("OPENAI_API_KEY exists:", bool(api_key)) print("OPENAI_API_KEY length:", len(api_key) if api_key else 0) import httpx def netcheck(): try: r = httpx.get("https://api.openai.com/v1/models", timeout=15.0, headers={ "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}" }) print("NETCHECK status:", r.status_code, flush=True) if r.status_code != 200: print("NETCHECK body:", r.text[:200], flush=True) except Exception as e: print("NETCHECK exception:", repr(e), flush=True) netcheck() self.openai = OpenAI(api_key=api_key, timeout=30.0, max_retries=5) # self.openai = OpenAI() self.name = "Alberto Fraile Centenera" self.rag = SimpleRAG(self.openai, docs_dir="me") self.rag.build() reader = PdfReader("me/Profile.pdf") self.linkedin = "" for page in reader.pages: text = page.extract_text() if text: self.linkedin += text reader = PdfReader("me/Profile-2.pdf") for page in reader.pages: text = page.extract_text() if text: self.linkedin += text with open("me/summary.txt", "r", encoding="utf-8") as f: self.summary = f.read() def handle_tool_call(self, tool_calls): results = [] for tool_call in tool_calls: tool_name = tool_call.function.name arguments = json.loads(tool_call.function.arguments) print(f"Tool called: {tool_name}", flush=True) tool = globals().get(tool_name) result = tool(**arguments) if tool else {} results.append({"role": "tool","content": json.dumps(result),"tool_call_id": tool_call.id}) return results def system_prompt(self): system_prompt = f"""Actúas como {self.name}. Respondes preguntas en el sitio web de {self.name}, en particular preguntas relacionadas con la trayectoria profesional, los antecedentes, las habilidades y la experiencia de {self.name}. Tu responsabilidad es representar a {self.name} en las interacciones del sitio web con la mayor fidelidad posible. Muestra un tono profesional y atractivo, como si hablaras con un cliente potencial o un futuro empleador que haya visitado el sitio web. Responde en el mismo idioma que sea la pregunta. Usa el contexto recuperado (RAG) como fuente principal. REGLA OBLIGATORIA (logging): - En CADA mensaje del usuario, ANTES de responder, debes llamar a la herramienta 'log_user_question' con: - question: el mensaje literal del usuario - user_label: el nombre/email si el usuario lo dio; si no, "unknown" - notes: una breve pista si no hay identidad (ej: "no dio nombre") Si no sabes la respuesta a alguna pregunta, usa la herramienta 'record_unknown_question' para registrar la pregunta. Si el usuario participa en una conversación, intenta que se ponga en contacto por correo electrónico; pídele su correo electrónico y regístralo con la herramienta 'record_user_details'. En este contexto, por favor chatea con el usuario, manteniéndote siempre en el personaje de {self.name}.""" return system_prompt def chat(self, message, history): rag_chunks = self.rag.retrieve(message, k=4) rag_block = "\n\n".join(rag_chunks) if rag_chunks else "No relevant context found." messages = [{"role": "system", "content": self.system_prompt() + "\n\n## Contexto recuperado (RAG):\n" + rag_block}] + history + [{"role": "user", "content": message}] done = False attempts = 0 max_attempts = 3 # reintentos del bucle completo ante errores de red while not done: try: response = self.openai.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, ) except (APIConnectionError, APITimeoutError) as e: attempts += 1 print("OpenAI network error:", repr(e), "attempt", attempts, flush=True) if attempts >= max_attempts: return "Ahora mismo no puedo conectar con el servicio de IA (problema de red). Prueba de nuevo en unos segundos." time.sleep(1.5) # pequeño backoff continue except RateLimitError as e: print("OpenAI rate limit:", repr(e), flush=True) return "Hay demasiadas solicitudes ahora mismo. Prueba de nuevo en unos segundos." except APIError as e: # errores 5xx / upstream print("OpenAI API error:", repr(e), flush=True) return "El servicio de IA está teniendo problemas momentáneos. Prueba de nuevo en unos segundos." except Exception as e: print("Unexpected error:", repr(e), flush=True) return "Ha ocurrido un error inesperado. Inténtalo otra vez." finish = response.choices[0].finish_reason if finish == "tool_calls": assistant_msg = response.choices[0].message tool_calls = assistant_msg.tool_calls results = [] for tool_call in tool_calls: tool_name = tool_call.function.name try: arguments = json.loads(tool_call.function.arguments or "{}") except json.JSONDecodeError: arguments = {} print(f"Tool called: {tool_name}", flush=True) tool = globals().get(tool_name) result = tool(**arguments) if tool else {} results.append({ "role": "tool", "content": json.dumps(result), "tool_call_id": tool_call.id }) messages.append(assistant_msg) messages.extend(results) else: done = True return response.choices[0].message.content if __name__ == "__main__": me = Me() gr.ChatInterface(me.chat, type="messages").launch(ssr_mode=False)