| """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 |
|
|
| |
| |
| |
| |
| ORCH_MAX_TOKENS = 512 |
| CHIME_MAX_TOKENS = 600 |
| REFLECT_MAX_TOKENS = 700 |
| TEMPERATURE = 0.7 |
| TOP_P = 0.95 |
| |
| HISTORY_TURNS = 100 |
|
|
|
|
| def _get(obj, key: str, default=None): |
| if isinstance(obj, dict): |
| return obj.get(key, default) |
| return getattr(obj, key, default) |
|
|
|
|
| |
| |
| |
| _THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE) |
|
|
|
|
| def _strip_reasoning(text: str) -> str: |
| """Remove a reasoning model's chain-of-thought, leaving the answer.""" |
| |
| text = _THINK_BLOCK_RE.sub("", text) |
| low = text.lower() |
| if "</think>" in low: |
| |
| text = text[low.rfind("</think>") + len("</think>"):] |
| elif "<think>" in low: |
| |
| |
| text = text[: low.find("<think>")] |
| 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 "" |
|
|
| |
| parts = _TAG_RE.sub("\n", content.replace("<br>", " ").replace("<br/>", " ")) |
| 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] |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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] |
|
|
|
|
| |
| |
| |
|
|
| 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.") |
|
|
|
|
| |
| |
| |
|
|
| 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?" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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 |
|
|