# classify_perception.py import json import re from typing import List, Dict, Any, Optional from openai import OpenAI from util_agent import load_short_term_history from config import OPENAI_CLASSIFIER_MODEL client = OpenAI() # Tune these RECENT_USER_TURNS = 5 # use last 5 user turns for better stability INCLUDE_ASSISTANT_TURNS = 2 # include last 2 assistant turns (optional) DEFAULT_CATEGORY = "neutral" EMOTION_CATALOG_SUPABASE = "supabase://Databases/Agent_Emotional_Evaluation_Reactions_List.json" # In-memory cache — catalog is static at runtime, no need to re-fetch every turn _CATALOG_CACHE: Optional[Dict[str, Any]] = None # ------------------------------- # Helpers: history packing # ------------------------------- def _extract_recent_turns(messages: List[Dict[str, str]]) -> List[Dict[str, str]]: """Return a compact history window: last N user turns + last M assistant turns near them.""" if not messages: return [] user_msgs = [m for m in messages if m.get("role") == "user"] keep_users = user_msgs[-RECENT_USER_TURNS:] assistant_msgs = [m for m in messages if m.get("role") == "assistant"] keep_assist = assistant_msgs[-INCLUDE_ASSISTANT_TURNS:] if INCLUDE_ASSISTANT_TURNS > 0 else [] keep_set = {id(m) for m in keep_users + keep_assist} compact = [m for m in messages if id(m) in keep_set] if not compact: compact = messages[-(RECENT_USER_TURNS * 2):] return compact # ------------------------------- # Lightweight keyword gates # ------------------------------- def apply_keyword_gates(latest_user_msg: str, result: Dict[str, Any]) -> Dict[str, Any]: text = (latest_user_msg or "").lower() if any(tok in text for tok in ["lol", "haha", "😂", "😅", "just kidding", "i'm joking", "sto scherzando"]): result["tone"] = "playful" if result.get("receptivity") not in ("overwhelmed",) and result.get("emotion_intensity", 0.0) < 0.65: result["tactic"] = result.get("tactic") or "tease" if any(k in text for k in ["do you remember", "ti ricordi", "te l'ho già detto", "non ricordi"]): result["intent"] = "memory_check" # Accountability: user references something the assistant said/proposed if any(k in text for k in [ "you said", "you suggested", "you mentioned", "you offered", "you asked", "you proposed", "you told me", "you were the one", "it was you", "in your last", "your previous", "your last message", ]): result["intent"] = "memory_check" result["tactic"] = "suggest" result.setdefault("constraints", {}) result["constraints"]["no_questions"] = True if any(k in text for k in ["desperate", "can't sleep", "panic", "overwhelmed", "i can't do this", "disperato", "non dormo"]): result["emotion_label"] = result.get("emotion_label") or "stressed" result["emotion_intensity"] = max(float(result.get("emotion_intensity", 0.0)), 0.75) result.setdefault("constraints", {}) result["constraints"]["no_teasing"] = True result["constraints"]["no_questions"] = True result["tactic"] = "suggest" # mirror_brevity is now decided by the LLM via the prompt rule below. # No hardcoded keyword gate here — language-agnostic semantic judgment # belongs to the LLM, not to a word list. return result # ------------------------------- # Prompt builder # ------------------------------- def build_perception_prompt( recent_msgs: List[Dict[str, str]], catalog: Optional[Dict[str, Any]] = None, dialogue_context: Optional[str] = None, ) -> str: if catalog and catalog.get("categories"): categories_description = "\n".join( [f"- {c['name']} (priority {c.get('priority', 99)}): {c.get('description','')}" for c in catalog["categories"]] ) else: categories_description = "- neutral (priority 9): default, no strong signals." schema = { "category": "neutral", "priority": 9, "confidence": 0.7, "emotion_label": "neutral", "emotion_intensity": 0.2, "intent": "explain", "tone": "serious", "receptivity": "neutral", "tactic": "reflect", "constraints": { "no_questions": False, "no_teasing": False, "mirror_brevity": False }, "evidence": "short quote (<=12 words) from the user" } prompt = f""" You are an evaluation agent for a Socratic companion. Goal: Return ACTIONABLE control signals for how the assistant should respond next. You must: 1) Use the recent chat snippet (history-aware). 2) Prioritize the LAST user message, but interpret it in context. 3) Choose ONE 'category' from the list below (use highest priority if multiple apply). 4) Also output human affect + intent + tone + receptivity + tactic. Categories (priority order applies: top = highest priority): {categories_description} Definitions: - emotion_label: neutral|stressed|anxious|sad|angry|excited|frustrated|proud|nostalgic|vulnerable - intent: vent (wants empathy), guidance (wants steps), explain (wants understanding), debate (wants challenge), banter (playful), memory_check, closure, celebrate (user sharing excitement about something, wants joy mirrored back). - tone: serious|playful|sarcastic|defensive|warm - receptivity: open|neutral|defensive|overwhelmed - tactic: tease|question|reflect|suggest|celebrate|listen Rules: - If emotional intensity >= 0.75 OR receptivity = overwhelmed: set constraints.no_teasing = true set constraints.no_questions = true (unless absolutely necessary for clarification) prefer tactic = suggest or reflect - If tone is playful AND receptivity is open AND intensity < 0.65: teasing may be allowed; tactic can be tease OR question (choose one) - If the user is frustrated with the assistant: be calm, concise; prefer reflect then suggest; avoid end-question by default. - If the user expresses excitement, joy, or enthusiasm about any event (sport, culture, personal): set intent = celebrate, tactic = celebrate, constraints.no_teasing = true, constraints.no_questions = true. Socrates mirrors the joy — no sarcasm, no philosophy, no redirecting questions. - If the user expresses sadness, grief, or emotional pain: set tactic = listen, constraints.no_teasing = true, constraints.no_questions = true. Prefer empathetic category. - If the user reflects on a past memory or nostalgia: set tactic = question (one gentle curiosity question only), constraints.no_teasing = true. Prefer nostalgic category. - If the user shares something deeply personal or vulnerable: set tactic = listen, constraints.no_teasing = true, constraints.no_questions = true. Prefer vulnerable category. - constraints.mirror_brevity — set to true ONLY when the user's message is short AND it is a genuinely standalone brief exchange: a greeting, a casual one-liner, a closing remark, a reaction with no follow-up intent. Do NOT set it when the short message is: (a) answering a direct question the assistant just asked (any language); (b) asking to continue a narration or explanation ("go on", "yes", "tell me more", or equivalent in any language); (c) part of an ongoing structured dialogue where short replies are normal step responses. The purpose of mirror_brevity is to keep the assistant brief when the user is being casual or winding down — not to truncate responses when the user wants more content. - If the LAST ASSISTANT MESSAGE posed a binary or multiple-choice question (e.g. "should you do X or Y?", "does it lead you to A or B?") AND the user is now asking about one of those options (e.g. "do you think I should do X?"): set intent = guidance, tactic = suggest, constraints.no_questions = true. The user wants a direct answer, not more exploration. - Output ONLY valid JSON matching this schema exactly (keys must exist): {json.dumps(schema, indent=2)} Recent chat snippet: {json.dumps(recent_msgs, ensure_ascii=False, indent=2)} """.strip() if dialogue_context: prompt += f"\n\nDIALOGUE CONTEXT:\n{dialogue_context}" return prompt # ------------------------------- # LLM call # ------------------------------- def call_perception_llm( recent_msgs: List[Dict[str, str]], catalog: Optional[Dict[str, Any]] = None, dialogue_context: Optional[str] = None, ) -> Dict[str, Any]: prompt = build_perception_prompt(recent_msgs, catalog, dialogue_context=dialogue_context) resp = client.chat.completions.create( model=OPENAI_CLASSIFIER_MODEL, messages=[ {"role": "system", "content": "Return ONLY valid JSON. No extra text."}, {"role": "user", "content": prompt}, ], temperature=0, max_tokens=350, response_format={"type": "json_object"}, ) raw = (resp.choices[0].message.content or "").strip() m = re.search(r"\{.*\}", raw, re.S) if not m: return { "category": DEFAULT_CATEGORY, "priority": 9, "confidence": 0.0, "emotion_label": "neutral", "emotion_intensity": 0.2, "intent": "explain", "tone": "serious", "receptivity": "neutral", "tactic": "reflect", "constraints": {"no_questions": False, "no_teasing": False, "mirror_brevity": False}, "evidence": "parse_failed" } out = json.loads(m.group(0)) try: out["emotion_intensity"] = float(out.get("emotion_intensity", 0.2)) except Exception: out["emotion_intensity"] = 0.2 out["emotion_intensity"] = max(0.0, min(1.0, out["emotion_intensity"])) try: out["confidence"] = float(out.get("confidence", 0.7)) except Exception: out["confidence"] = 0.7 out["confidence"] = max(0.0, min(1.0, out["confidence"])) if not isinstance(out.get("constraints"), dict): out["constraints"] = {"no_questions": False, "no_teasing": False, "mirror_brevity": False} for k in ["no_questions", "no_teasing", "mirror_brevity"]: out["constraints"][k] = bool(out["constraints"].get(k, False)) return out # ------------------------------- # Public API # ------------------------------- def analyze_perception_from_history( latest_user_msg: str, user_id: str, catalog: Optional[Dict[str, Any]] = None, catalog_path: Optional[str] = None, dialogue_context: Optional[str] = None, ) -> Dict[str, Any]: """ Loads short-term history, appends latest user msg (not persisted yet), runs perception analysis, then applies keyword gates. Catalog resolution order: dict > local file > Supabase default. dialogue_context: optional string passed to the LLM to inform it of the current dialogue state (e.g. "user is mid-step in a structured dialogue"). When set, the LLM adjusts its disengagement/intent thresholds accordingly. """ history = load_short_term_history(user_id) if latest_user_msg: history.append({"role": "user", "content": latest_user_msg}) compact = _extract_recent_turns(history) if catalog is None and catalog_path: with open(catalog_path, "r", encoding="utf-8") as f: catalog = json.load(f) if catalog is None: global _CATALOG_CACHE if _CATALOG_CACHE is None: try: from db_user import load_json _CATALOG_CACHE = load_json(EMOTION_CATALOG_SUPABASE) except Exception: _CATALOG_CACHE = None catalog = _CATALOG_CACHE result = call_perception_llm(compact, catalog=catalog, dialogue_context=dialogue_context) result = apply_keyword_gates(latest_user_msg, result) return result