Spaces:
Sleeping
Sleeping
| 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) | |