"""The emotion agents and the orchestrator that decides who speaks. Flow for each user message: 1. An orchestrator picks the 2-4 emotions most relevant to the message. 2. Each chosen emotion replies *in character*, in parallel. 3. A gentle "reflection" reads the room and helps the user name what's really going on underneath. Everything degrades gracefully: with no model token (or no network) the app falls back to a lightweight keyword-based simulation so the UI is always usable. """ from __future__ import annotations import html import json import re from concurrent.futures import ThreadPoolExecutor from emotions import EMOTIONS, EMOTION_ORDER # Token budgets. These are generous on purpose: "reasoning"/"thinking" models # (Nemotron, Qwen3, gemma-with-thinking, etc.) spend tokens thinking *before* # they emit the visible answer, so a tight budget leaves content empty. The # prompts still ask for 1-2 short sentences, so non-reasoning models stop early. ORCH_MAX_TOKENS = 512 CHIME_MAX_TOKENS = 600 REFLECT_MAX_TOKENS = 700 TEMPERATURE = 0.7 TOP_P = 0.95 # How many of the most recent turns to feed back to the agents as context. HISTORY_TURNS = 100 def _get(obj, key: str, default=None): if isinstance(obj, dict): return obj.get(key, default) return getattr(obj, key, default) # Reasoning models wrap their thoughts in ... tags inside the # content (or put them in a separate field). Strip the thinking so we keep only # the user-facing answer. _THINK_BLOCK_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE) def _strip_reasoning(text: str) -> str: """Remove a reasoning model's chain-of-thought, leaving the answer.""" # Drop any complete ... blocks. text = _THINK_BLOCK_RE.sub("", text) low = text.lower() if "" in low: # Answer is whatever follows the final closing tag. text = text[low.rfind("") + len(""):] elif "" in low: # Only an opening tag: the model ran out of budget mid-thought and never # reached an answer. Keep anything before it (usually nothing). text = text[: low.find("")] return text.strip() def _chat_text( client, messages: list[dict[str, str]], *, max_tokens: int, ) -> str: """Call the Hugging Face chat-completion client and return response text.""" resp = client.chat_completion( messages, max_tokens=max_tokens, temperature=TEMPERATURE, top_p=TOP_P, ) choices = _get(resp, "choices", []) if not choices: return "" choice = choices[0] message = _get(choice, "message") if message is not None: content = _get(message, "content", "") if content is None: content = "" if isinstance(content, list): content = "".join(str(_get(part, "text", part)) for part in content) return _strip_reasoning(str(content)) return _strip_reasoning(str(_get(choice, "text", ""))) _TAG_RE = re.compile(r"<[^>]+>") def _readable(turn: dict) -> str: """Turn one stored chat entry into a clean 'Speaker: text' line. User messages are already plain text. Assistant entries are the HTML bubbles produced in app.py, so we strip the markup and recover the emotion's name (the bubble's first inner line) as the speaker label. """ role = turn.get("role") content = str(turn.get("content", "")) if role == "user": text = content.strip() return f"You: {text}" if text else "" # Assistant bubble: split on tags, then unescape, to get [label, body...]. parts = _TAG_RE.sub("\n", content.replace("
", " ").replace("
", " ")) chunks = [html.unescape(p).strip() for p in parts.splitlines()] chunks = [c for c in chunks if c] if not chunks: return "" if len(chunks) == 1: return f"Emotions: {chunks[0]}" label = chunks[0] # e.g. "😨 Fear" or "A gentle reflection" body = " ".join(chunks[1:]) return f"{label}: {body}" def _history_text(history: list[dict]) -> str: """Flatten recent conversation into a clean, readable transcript.""" lines = [_readable(turn) for turn in history[-HISTORY_TURNS:]] return "\n".join(line for line in lines if line) # --------------------------------------------------------------------------- # # Orchestrator: who should chime in? # --------------------------------------------------------------------------- # def choose_emotions(message: str, history: list[dict], client) -> list[str]: if client is None: return _fallback_choose(message) roster = "\n".join( f"- {EMOTIONS[k].name}: {EMOTIONS[k].tagline}" for k in EMOTION_ORDER ) prompt = ( "You are the control console inside someone's mind, like in the movie " "Inside Out. Read the person's latest message and decide which emotions " "would naturally light up and want to speak.\n\n" f"The emotions available are:\n{roster}\n\n" f"Recent conversation:\n{_history_text(history)}\n\n" f"Latest message: \"{message}\"\n\n" "Pick the 2 to 4 emotions that fit best. Favor an interesting, honest " "mix rather than always the same crowd. Respond with ONLY a JSON array " "of lowercase emotion keys from this set: " f"{json.dumps(EMOTION_ORDER)}." ) try: text = _chat_text( client, [{"role": "user", "content": prompt}], max_tokens=ORCH_MAX_TOKENS, ) match = re.search(r"\[.*\]", text, re.DOTALL) keys = json.loads(match.group(0)) if match else [] keys = [k for k in keys if k in EMOTIONS] if keys: return keys[:4] except Exception: pass return _fallback_choose(message) def _fallback_choose(message: str) -> list[str]: """Keyword heuristic used when no model is available.""" m = message.lower() buckets = { "joy": ["happy", "great", "excited", "love", "win", "good", "fun", "yay", "proud"], "sadness": ["sad", "lonely", "miss", "lost", "cry", "hurt", "down", "grief", "tired"], "anxiety": ["worried", "anxious", "nervous", "stress", "deadline", "what if", "scared of"], "fear": ["afraid", "fear", "danger", "risk", "unsafe", "panic"], "anger": ["angry", "mad", "unfair", "annoyed", "furious", "hate", "frustrat"], "envy": ["jealous", "envy", "wish i", "they have", "compare", "better than me"], "embarrassment": ["embarrass", "awkward", "cringe", "ashamed", "stupid", "regret"], "disgust": ["gross", "disgust", "toxic", "fake", "ew", "hate the"], "ennui": ["bored", "meh", "whatever", "pointless", "dull", "nothing matters"], } hits = [k for k in EMOTION_ORDER if any(w in m for w in buckets[k])] if not hits: hits = ["joy", "sadness", "anxiety"] return hits[:4] # --------------------------------------------------------------------------- # # Individual emotion replies # --------------------------------------------------------------------------- # def emotion_reply(key: str, message: str, history: list[dict], client) -> str: emo = EMOTIONS[key] if client is None: return _fallback_line(key, message) system = ( f"{emo.persona}\n\n" "You are one of several emotions reacting to the same person inside " "their head. Speak in first person, directly to them, in your own " "distinct voice. Keep it to 1-2 short, natural sentences. Stay fully in " "character. Do not narrate or use stage directions. Be warm and human." ) user = ( f"Recent conversation:\n{_history_text(history)}\n\n" f"Their latest message: \"{message}\"\n\n" f"React as {emo.name}." ) try: text = _chat_text( client, [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], max_tokens=CHIME_MAX_TOKENS, ) return text or _fallback_line(key, message) except Exception: return _fallback_line(key, message) _FALLBACK_LINES = { "joy": "Hey, there's something good we can hold onto here — let's find it together!", "sadness": "It's okay if this feels heavy. I'm right here with you while it does.", "fear": "Just so we're careful — what's the part of this that feels risky to you?", "anger": "Wait, is this actually fair to you? You're allowed to take up space.", "disgust": "Ugh, honestly? You deserve better than whatever this is.", "anxiety": "Okay okay — let's think ahead. What's the plan if things don't go smoothly?", "envy": "I notice a little wanting in there... what is it you really wish you had?", "embarrassment": "I keep wondering how this looks to other people... but maybe that's okay.", "ennui": "Eh. Does this even matter to you, or are we just going through the motions?", } def _fallback_line(key: str, message: str) -> str: return _FALLBACK_LINES.get(key, "I'm feeling something about this too.") # --------------------------------------------------------------------------- # # Gentle closing reflection # --------------------------------------------------------------------------- # def reflection(message: str, replies: list[tuple[str, str]], client) -> str: if client is None: names = ", ".join(EMOTIONS[k].name for k, _ in replies) return ( f"It sounds like {names} all showed up for you here. " "Which of them feels the most true right now?" ) spoken = "\n".join(f"{EMOTIONS[k].name}: {text}" for k, text in replies) prompt = ( "You are a calm, caring inner guide helping someone understand their " "feelings, in the spirit of Inside Out. The person said:\n" f"\"{message}\"\n\n" "Their emotions responded:\n" f"{spoken}\n\n" "In 1-2 warm sentences, gently reflect back what might be going on " "underneath for them, and invite them to notice which feeling rings " "most true. Do not list the emotions mechanically. Be tender, not " "clinical. End with a soft, open question." ) try: text = _chat_text( client, [{"role": "user", "content": prompt}], max_tokens=REFLECT_MAX_TOKENS, ) if text: return text except Exception: pass names = ", ".join(EMOTIONS[k].name for k, _ in replies) return ( f"It sounds like {names} all showed up for you here. " "Which of them feels the most true right now?" ) # --------------------------------------------------------------------------- # # Top-level turn # --------------------------------------------------------------------------- # def run_turn(message: str, history: list[dict], client=None): """Run one full turn. Returns (replies, reflection_text) where replies is a list of (emotion_key, text) tuples in a natural speaking order. """ keys = choose_emotions(message, history, client) # Respond in parallel for snappiness, then restore the chosen order. with ThreadPoolExecutor(max_workers=len(keys) or 1) as pool: texts = list( pool.map(lambda k: emotion_reply(k, message, history, client), keys) ) replies = list(zip(keys, texts)) reflect = reflection(message, replies, client) return replies, reflect