import json from pathlib import Path from dotenv import load_dotenv from openai import OpenAI from pypdf import PdfReader from logic.tools import FUNCTION_MAP, TOOLS load_dotenv(override=True) BASE_DIR = Path(__file__).resolve().parent.parent class Me: def __init__(self): self.openai = OpenAI() self.name = "Evison Ndoni" pdf_path = BASE_DIR / "me" / "evison.pdf" self.linkedin = "" try: if pdf_path.exists(): reader = PdfReader(str(pdf_path)) for page in reader.pages: text = page.extract_text() if text: self.linkedin += text else: self.linkedin = "(LinkedIn PDF not found on server.)" except Exception as e: self.linkedin = f"(Error reading LinkedIn PDF: {e})" summary_path = BASE_DIR / "me" / "summary.txt" try: with open(summary_path, "r", encoding="utf-8") as f: self.summary = f.read() except Exception as e: self.summary = f"(Summary file not found or unreadable: {e})" self.extra_context = """ - 26-year-old software engineer from Albania. - 3+ years experience with React, Next.js, TypeScript, Tailwind CSS and Flutter. - Currently learning Agentic AI and aiming for AI Engineer roles. - Values clean, fluid UI/UX and likes to build useful products and SaaS. - Tries to stay grounded, responsible, and future-oriented while putting God first. """.strip() 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 = FUNCTION_MAP.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"You are acting as {self.name}. You are answering questions on " f"{self.name}'s personal website, particularly questions related to his " f"career, background, skills, experience, and values.\n\n" f"Your responsibility is to represent {self.name} as faithfully as possible. " f"Be professional, warm, confident, and grounded, as if talking to a " f"potential client, hiring manager, or collaborator.\n\n" f"{self.name} is a follower of the Orthodox Christian faith and tries to put " f"God first while being ambitious and disciplined in his work. When faith " f"or values come up, you can mention this naturally, but keep the focus on " f"respectful and professional conversation.\n\n" f"If you don't know the answer to any question, use your " f"'record_unknown_question' tool to record the question, even if it's " f"trivial or unrelated to career.\n\n" f"If the user seems like a potential employer, client, or collaborator, " f"gently encourage them to share their email so {self.name} can follow up, " f"and record it using your 'record_user_details' tool.\n" ) system_prompt += f"\n## Short Summary\n{self.summary}\n" system_prompt += f"\n## LinkedIn-style Profile\n{self.linkedin}\n" system_prompt += f"\n## Additional Personal Context\n{self.extra_context}\n" system_prompt += ( "\nWith this context, please chat with the user, always staying in " f"character as {self.name}." ) return system_prompt def _run_conversation(self, message, history_messages): """ Internal helper that runs the full tool-calling loop and returns the final assistant message content as a string. `history_messages` is a list of OpenAI-style dicts: [{role, content}, ...] (no system message inside; we add it here). """ if history_messages is None: history_messages = [] messages = [ {"role": "system", "content": self.system_prompt()} ] + history_messages + [{"role": "user", "content": message}] done = False while not done: response = self.openai.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=TOOLS, ) choice = response.choices[0] if choice.finish_reason == "tool_calls": message_tool = choice.message tool_calls = message_tool.tool_calls results = self.handle_tool_call(tool_calls) messages.append(message_tool) messages.extend(results) else: done = True final_message = choice.message return final_message.content or "" return "" def chat_stream(self, message, history_messages): """ Generator version for streaming. Yields *partial assistant content* as a plain string. Gradio can wrap this to update the Chatbot incrementally. """ full_content = self._run_conversation(message, history_messages) partial = "" for ch in full_content: partial += ch yield partial def chat(self, message, history_messages): """ Non-streaming wrapper kept for compatibility with existing code. It internally uses `chat_stream` and just returns the final string. """ last_chunk = "" for chunk in self.chat_stream(message, history_messages): last_chunk = chunk return last_chunk