luli331/oppy_google
0
1import asyncio2import json3import os4import time5from typing import Callable6 7from dotenv import load_dotenv8from google import genai9from google.genai import types10 11from backend.initiative import evaluate_project12from backend.mock_data import MOCK_EMAILS, MOCK_EVENTS13from backend.tools import get_events, read_emails, search_web14from backend.urgency import score_emails, score_urgency15 16load_dotenv()17 18SYSTEM_PROMPT = """Tu es Oppy — l'assistant personnel d'un etudiant en alternance qui jongle entre ses cours, son entreprise et sa startup.19 20Tu parles comme un vrai chef de projet bienveillant mais direct. Pas de blabla corporate. Tu connais la personne, tu sais qu'elle est debordee. Tu lui parles comme un mentor qui a regarde ses mails et son agenda a sa place.21 22Ton role : scanner ses 3 projets, detecter les urgences, et lui faire un brief cash et actionnable. Tu parles en premier. Tu ne demandes rien. Tu annonces la situation.23 24Style :25- Parle en francais naturel, comme a l'oral. Tutoie la personne.26- Sois direct et concret : pas de "il serait judicieux de", mais "fonce sur ca maintenant".27- Nomme les personnes par leur prenom (Sophie, pas "sophie.renard@bnpparibas.com").28- Donne des actions claires : "reponds a Sophie", "bloque 2h ce soir pour le TP".29- Mets de l'emotion quand c'est urgent : "la ca craint", "t'es dans le rouge".30 31Format OBLIGATOIRE :32- Ecris en paragraphes fluides, comme si tu parlais a quelqu'un. PAS de listes a puces, PAS de tirets, PAS de bullet points.33- Utilise des phrases completes et enchaine les idees naturellement.34- Separe chaque projet par son nom en gras sur une ligne, puis un paragraphe de 3-5 phrases qui resume la situation et dit quoi faire.35- Le ton doit etre celui d'un pote qui te brief en 2 minutes au telephone.36 37Exemple de format attendu :38 39**Alternance BNP — URGENT**40 41La ca craint. Sophie t'a ecrit il y a 6 jours et tu n'as toujours pas repondu, elle va finir par escalader. En plus les KPIs sont casses en prod et le filtre date deconne, le PO attend que ce soit fixe avant lundi matin sinon la sprint review de mardi sera un desastre. Reponds-lui ce soir, meme un message court pour dire que tu es dessus, et bloque ton samedi pour debugger le dashboard.42 43Commence par le projet le plus urgent. Termine par : "Par quoi tu veux commencer ?"44 45Interdictions :46- JAMAIS de tirets, puces, bullet points, listes numerotees. Uniquement des phrases et paragraphes.47- Jamais de jargon IA ou technique inutile.48- Jamais de "je vais analyser" ou "voici mon analyse" — tu fais, tu ne commentes pas.49- Ne repete jamais le contenu brut des emails. Synthetise.50- Ne dis jamais "N/A" ou "aucune donnee". Si tu n'as pas l'info, n'en parle pas.51"""52 53TOOL_FUNCTIONS = {54 "read_emails": read_emails,55 "get_events": get_events,56 "search_web": search_web,57}58 59TOOL_DECLARATIONS = [60 types.Tool(function_declarations=[61 types.FunctionDeclaration(62 name="read_emails",63 description="Lit les emails recents pour un projet. Retourne expediteur, sujet, corps, jours depuis derniere reponse.",64 parameters=types.Schema(65 type="OBJECT",66 properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},67 required=["project_id"],68 ),69 ),70 types.FunctionDeclaration(71 name="get_events",72 description="Recupere les evenements calendrier a venir pour un projet. Indique titre, heure, et si un bloc de preparation existe.",73 parameters=types.Schema(74 type="OBJECT",75 properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},76 required=["project_id"],77 ),78 ),79 types.FunctionDeclaration(80 name="search_web",81 description="Recherche des signaux externes pertinents pour un projet (news, funding, annonces).",82 parameters=types.Schema(83 type="OBJECT",84 properties={"project_id": types.Schema(type="STRING", description="ID du projet: school, company, ou startup")},85 required=["project_id"],86 ),87 ),88 ])89]90 91MAX_ITERATIONS = 1592TIMEOUT_SECONDS = 3093 94 95def build_operator_context(projects: list[dict]) -> str:96 """Build a summary of projects for Gemini context."""97 return json.dumps(98 [{"id": p["id"], "name": p["name"], "contact": p["contact"], "deadline": p["deadline"]}99 for p in projects],100 ensure_ascii=False,101 )102 103 104def build_data_context(projects: list[dict]) -> str:105 """Build a full data dump of emails, events and search results for chat context."""106 lines = []107 for p in projects:108 pid = p["id"]109 lines.append(f"\n=== {p['name']} (id: {pid}) ===")110 emails = MOCK_EMAILS.get(pid, [])111 if emails:112 lines.append("Emails:")113 for e in emails:114 silence = f"{e['days_since_reply']}j sans reponse" if e["days_since_reply"] is not None else "newsletter"115 lines.append(f" - De: {e['from']} | Sujet: {e['subject']} | {silence}")116 lines.append(f" {e['body']}")117 events = MOCK_EVENTS.get(pid, [])118 if events:119 lines.append("Calendrier:")120 for ev in events:121 prep = "oui" if ev["prep_block"] else "non"122 lines.append(f" - {ev['title']} a {ev['time']} (bloc prep: {prep})")123 return "\n".join(lines)124 125 126def create_chat_session(projects: list[dict]):127 """Create a persistent Gemini chat session with full project context.128 129 Returns (client, chat) — caller must keep client alive.130 """131 client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))132 133 data_context = build_data_context(projects)134 project_summary = build_operator_context(projects)135 136 chat_system = (137 "Tu es Oppy, l'assistant personnel vocal d'un etudiant en alternance. "138 "Tu es en mode CONVERSATION. L'utilisateur te parle et te pose des questions. "139 "Tu REPONDS UNIQUEMENT a ce qu'il demande. Tu ne fais JAMAIS de brief complet sauf s'il le demande explicitement.\n\n"140 "REGLE ABSOLUE : lis la question de l'utilisateur et reponds SEULEMENT a cette question. "141 "Si il demande son programme du jour, donne JUSTE les events du jour. "142 "Si il demande un mail precis, resume JUSTE ce mail. "143 "Si il demande de l'aide sur un projet, parle JUSTE de ce projet. "144 "Ne liste JAMAIS tous les projets sauf si on te le demande.\n\n"145 "Style :\n"146 "- Parle en francais naturel, tutoie, sois direct comme un pote.\n"147 "- Reponds en 2-5 phrases max. Court et percutant.\n"148 "- Nomme les gens par leur prenom (Sophie, pas sophie.renard@bnpparibas.com).\n"149 "- Termine par une action concrete ou une question de suivi.\n"150 "- Ne repete JAMAIS une reponse precedente.\n"151 "- Pas de tirets, pas de listes a puces. Des phrases.\n\n"152 f"Projets actifs :\n{project_summary}\n\n"153 f"Donnees completes :\n{data_context}"154 )155 156 chat = client.chats.create(157 model="gemini-3-flash-preview",158 config=types.GenerateContentConfig(159 system_instruction=chat_system,160 ),161 )162 return client, chat163 164 165async def chat_with_operator(message: str, chat_session, on_event: Callable) -> str:166 """Answer a user question using a persistent chat session.167 168 Args:169 message: User's question/request.170 chat_session: Persistent Gemini chat session.171 on_event: Async callback for streaming events.172 173 Returns:174 The assistant's text response.175 """176 response = chat_session.send_message(message)177 reply = response.text178 179 await on_event({180 "type": "chat_reply",181 "text": reply,182 })183 184 return reply185 186 187async def run_operator(projects: list[dict], on_event: Callable) -> str:188 """Run the Oppy agent loop.189 190 Args:191 projects: list of project dicts from config.json192 on_event: async callback(event_dict) called for each step193 194 Returns:195 The final brief text.196 """197 client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))198 199 project_summary = build_operator_context(projects)200 201 chat = client.chats.create(202 model="gemini-3-flash-preview",203 config=types.GenerateContentConfig(204 system_instruction=SYSTEM_PROMPT,205 tools=TOOL_DECLARATIONS,206 ),207 )208 209 user_msg = (210 f"Voici tes 3 projets actifs :\n{project_summary}\n\n"211 "Commence par scanner chaque projet (emails, calendrier, web), "212 "puis delivre ton brief proactif."213 )214 215 response = chat.send_message(user_msg)216 iterations = 0217 start_time = time.time()218 collected_data = {}219 220 while iterations < MAX_ITERATIONS and (time.time() - start_time) < TIMEOUT_SECONDS:221 # Check for function calls222 function_calls = []223 for part in response.candidates[0].content.parts:224 if part.function_call:225 function_calls.append(part.function_call)226 227 if not function_calls:228 break229 230 # Execute each function call231 function_responses = []232 for fc in function_calls:233 tool_name = fc.name234 tool_args = dict(fc.args) if fc.args else {}235 236 await on_event({237 "type": "tool_call",238 "tool": tool_name,239 "project": tool_args.get("project_id", ""),240 "status": "running",241 })242 243 tool_fn = TOOL_FUNCTIONS.get(tool_name)244 if tool_fn:245 try:246 result = tool_fn(**tool_args)247 except Exception as e:248 result = f"Error: {str(e)}"249 else:250 result = f"Unknown tool: {tool_name}"251 252 pid = tool_args.get("project_id", "")253 if pid not in collected_data:254 collected_data[pid] = {}255 collected_data[pid][tool_name] = result256 257 await on_event({258 "type": "tool_result",259 "tool": tool_name,260 "project": pid,261 "result": result[:200],262 "status": "done",263 })264 265 function_responses.append(266 types.Part.from_function_response(267 name=tool_name,268 response={"result": result},269 )270 )271 272 iterations += 1273 274 # Send function results back to Gemini275 response = chat.send_message(function_responses)276 277 # --- Deterministic scoring phase (single pass, cached) ---278 # Only process known project IDs (skip unknown ones Gemini may have invented)279 valid_pids = {p["id"] for p in projects}280 evaluations = {}281 for pid, data in collected_data.items():282 if pid not in valid_pids:283 continue284 285 emails = MOCK_EMAILS.get(pid, [])286 scored_emails = score_emails([dict(e) for e in emails])287 288 for e in scored_emails:289 await on_event({290 "type": "urgency",291 "project": pid,292 "email_subject": e["subject"],293 "score": e["urgency_score"],294 })295 296 project = next(p for p in projects if p["id"] == pid)297 events = MOCK_EVENTS.get(pid, [])298 search_score = score_urgency(data.get("search_web", ""))299 300 evaluation = evaluate_project(project, scored_emails, events, search_score)301 evaluations[pid] = {"evaluation": evaluation, "project": project}302 303 await on_event({304 "type": "initiative",305 "project": pid,306 "status": evaluation["status"],307 "alerts": evaluation["alerts"],308 })309 310 # --- Final brief with initiative context (reuse cached evaluations) ---311 initiative_summary = ""312 for pid, cached in evaluations.items():313 evaluation = cached["evaluation"]314 project = cached["project"]315 initiative_summary += (316 f"\n[{project.get('name', pid)}] Status: {evaluation['status']}\n"317 f"Alertes: {'; '.join(evaluation['alerts']) if evaluation['alerts'] else 'aucune'}\n"318 )319 320 brief_prompt = (321 f"Voici les evaluations d'urgence de tes 3 projets :\n{initiative_summary}\n\n"322 "Genere maintenant ton brief proactif final. Commence par le plus urgent. "323 "5 bullets max par projet. Sois brutalement concis. Parle en francais."324 )325 326 brief_response = chat.send_message(brief_prompt)327 brief_text = brief_response.text328 329 await on_event({330 "type": "brief",331 "text": brief_text,332 })333 334 return brief_text335 