mindsphere_coach / src /mindsphere /llm /classifier.py
Mahault
Batch emotion + profile extraction into single LLM call per coaching turn
e3562aa
"""
SphereClassifier: converts user text into structured observations for the POMDP.
Adapted from NEXT-prototype's MistralClassifier pattern.
Three classification modes: MC answers, free text, and user choices.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
from .client import MistralClient, MistralAPIError
CLASSIFIER_SYSTEM_PROMPT = """\
You are an observation classifier for a personal coaching system called MindSphere Coach.
Your job is to analyze a user's response and classify it into structured observations.
You will be given the context (what type of input to classify) in the user message.
Always respond with a valid JSON object, no other text."""
MC_CLASSIFY_PROMPT = """\
The user answered a multiple-choice question about "{category}".
Question: {question}
Options:
{options_text}
User's answer: "{answer}"
Classify into JSON:
{{
"answer_index": <int 0-{max_idx}>,
"confidence": "low" | "medium" | "high",
"engagement_signal": "low" | "medium" | "high"
}}"""
FREE_TEXT_CLASSIFY_PROMPT = """\
The user gave a free-text response to the coaching question: "{question}"
User's response: "{answer}"
Classify into JSON:
{{
"primary_categories": [list of relevant skills from: "focus", "follow_through", "social_courage", "emotional_reg", "systems_thinking", "self_trust", "task_clarity", "consistency"],
"skill_signals": {{category: "very_low" | "low" | "medium" | "high" | "very_high"}},
"emotional_tone": "resistant" | "neutral" | "engaged" | "enthusiastic",
"friction_signal": "avoidant" | "neutral" | "eager",
"autonomy_signal": "compliant" | "neutral" | "assertive",
"overwhelm_signal": "low" | "medium" | "high"
}}"""
CHOICE_CLASSIFY_PROMPT = """\
The user was presented with a coaching micro-step and responded.
Proposed step: "{intervention_description}"
User's response: "{answer}"
Classify into JSON:
{{
"choice": "accept" | "too_hard" | "not_relevant",
"engagement": "low" | "medium" | "high",
"resistance_type": "none" | "overwhelm" | "boredom" | "uncertainty" | "self_doubt" | "resentment"
}}"""
EMOTION_CLASSIFY_PROMPT = """\
Analyze the emotional content of this message using the Circumplex Model of Emotion \
(two dimensions: valence and arousal).
User's message: "{answer}"
Context: {context}
Classify into JSON:
{{
"valence": "very_negative" | "negative" | "neutral" | "positive" | "very_positive",
"arousal": "very_low" | "low" | "moderate" | "high" | "very_high",
"primary_emotion": "happy" | "excited" | "alert" | "angry" | "sad" | "depressed" | "calm" | "relaxed",
"confidence": "low" | "medium" | "high",
"emotional_cues": [list of specific words/phrases that signal the emotion]
}}
Guidelines:
- Valence = how positive or negative the person feels (prediction error: better or worse than expected)
- Arousal = how activated or calm the person is (uncertainty/alertness level)
- "I'm bored" = negative valence + low arousal = depressed quadrant
- "This is exciting!" = positive valence + high arousal = excited quadrant
- "I'm stressed about work" = negative valence + high arousal = angry/anxious quadrant
- "I feel peaceful today" = positive valence + low arousal = relaxed quadrant
- Short, flat responses suggest low arousal
- Exclamation marks, urgent language suggest high arousal
- Complaints, frustration, sadness suggest negative valence
- Curiosity, enthusiasm, gratitude suggest positive valence"""
EMOTION_AND_PROFILE_PROMPT = """\
Analyze this user message for BOTH emotional state AND personal information.
User's message: "{answer}"
Context: {context}
{existing_summary}
Return a single JSON object with two keys: "emotion" and "profile".
"emotion" — Circumplex Model classification:
{{
"valence": "very_negative" | "negative" | "neutral" | "positive" | "very_positive",
"arousal": "very_low" | "low" | "moderate" | "high" | "very_high",
"primary_emotion": "happy" | "excited" | "alert" | "angry" | "sad" | "depressed" | "calm" | "relaxed",
"confidence": "low" | "medium" | "high",
"emotional_cues": [list of specific words/phrases that signal the emotion]
}}
Emotion guidelines:
- Valence = how positive or negative the person feels
- Arousal = how activated or calm the person is
- Short, flat responses suggest low arousal
- Exclamation marks, urgent language suggest high arousal
"profile" — Extract meaningful personal facts:
{{
"facts": [
{{"content": "...", "category": "life_event|goal|challenge|interest|context|strength|decision|self_insight", "significance": "low|medium|high", "valence": "positive|negative|neutral", "related_skills": ["focus", "emotional_reg", ...]}}
],
"causal_links": [
{{"from": "fact or existing fact", "to": "inferred effect", "strength": 0.0-1.0, "relationship": "increases|decreases|triggers|blocks", "to_valence": "positive|negative|neutral"}}
],
"progress_signals": [
{{"skill": "focus|follow_through|social_courage|emotional_reg|systems_thinking|self_trust|task_clarity|consistency", "direction": "improvement|regression", "magnitude": 0.1-0.5, "evidence": "what the user said"}}
]
}}
Profile rules:
- Only extract genuinely meaningful information, not trivial filler
- related_skills: focus, follow_through, social_courage, emotional_reg, systems_thinking, self_trust, task_clarity, consistency
- progress_signals: only when user reports actual behavioral change
- Return empty arrays if the message has no significant personal content
- Keep fact content concise (under 100 chars)"""
class SphereClassifier:
"""
Converts user text into structured observations for the POMDP.
Acts as the "sensor" in the active inference loop.
Falls back to defaults on API errors to keep the session running.
Includes emotional classification for the Circumplex Model:
classifies user text into valence/arousal observations that
become formal POMDP observations for emotional state inference.
"""
DEFAULT_MC_RESULT = {
"answer_index": 1,
"confidence": "medium",
"engagement_signal": "medium",
}
DEFAULT_FREE_TEXT_RESULT = {
"primary_categories": [],
"skill_signals": {},
"emotional_tone": "neutral",
"friction_signal": "neutral",
"autonomy_signal": "neutral",
"overwhelm_signal": "medium",
}
DEFAULT_CHOICE_RESULT = {
"choice": "accept",
"engagement": "medium",
"resistance_type": "none",
}
DEFAULT_EMOTION_RESULT = {
"valence": "neutral",
"arousal": "moderate",
"primary_emotion": "calm",
"confidence": "medium",
"emotional_cues": [],
}
# Maps for converting string levels to observation indices
VALENCE_INDEX_MAP = {
"very_negative": 0, "negative": 1, "neutral": 2,
"positive": 3, "very_positive": 4,
}
AROUSAL_INDEX_MAP = {
"very_low": 0, "low": 1, "moderate": 2,
"high": 3, "very_high": 4,
}
def __init__(self, client: MistralClient):
self.client = client
def classify_mc_answer(
self,
answer_text: str,
question: str,
category: str,
options: List[str],
) -> Dict[str, Any]:
"""Classify a multiple-choice answer."""
options_text = "\n".join(f" {i}. {opt}" for i, opt in enumerate(options))
prompt = MC_CLASSIFY_PROMPT.format(
category=category,
question=question,
options_text=options_text,
answer=answer_text,
max_idx=len(options) - 1,
)
return self._call_llm(prompt, self.DEFAULT_MC_RESULT)
def classify_free_text(
self,
answer_text: str,
question: str,
) -> Dict[str, Any]:
"""Classify a free-text response."""
prompt = FREE_TEXT_CLASSIFY_PROMPT.format(
question=question,
answer=answer_text,
)
return self._call_llm(prompt, self.DEFAULT_FREE_TEXT_RESULT)
def classify_user_choice(
self,
answer_text: str,
intervention_description: str,
) -> Dict[str, Any]:
"""Classify a user's response to a proposed intervention."""
prompt = CHOICE_CLASSIFY_PROMPT.format(
intervention_description=intervention_description,
answer=answer_text,
)
return self._call_llm(prompt, self.DEFAULT_CHOICE_RESULT)
def classify_emotion(
self,
answer_text: str,
context: str = "general coaching conversation",
) -> Dict[str, Any]:
"""
Classify user text into circumplex emotional observations.
Returns valence (0-4) and arousal (0-4) indices plus emotion label.
These become formal POMDP observations for the emotional state model.
Falls back to heuristic classification if LLM fails, rather than
returning neutral defaults (which would make the system emotionally blind).
"""
prompt = EMOTION_CLASSIFY_PROMPT.format(
answer=answer_text,
context=context,
)
try:
response = self.client.chat_completion(
messages=[
{"role": "system", "content": CLASSIFIER_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
temperature=0.05,
max_tokens=256,
response_format={"type": "json_object"},
)
result = self._validate_and_repair(
json.loads(response), self.DEFAULT_EMOTION_RESULT
)
except Exception as e:
# LLM failed — fall back to heuristic instead of neutral defaults
logger.warning(f"[SphereClassifier] Emotion LLM failed ({type(e).__name__}), using heuristic")
result = self.classify_emotion_heuristic(answer_text)
# Convert string levels to indices
result["valence_idx"] = self.VALENCE_INDEX_MAP.get(
result.get("valence", "neutral"), 2
)
result["arousal_idx"] = self.AROUSAL_INDEX_MAP.get(
result.get("arousal", "moderate"), 2
)
return result
def classify_emotion_and_extract_profile(
self,
answer_text: str,
context: str = "general coaching conversation",
existing_summary: str = "",
) -> Dict[str, Any]:
"""
Batch call: classify emotion AND extract profile facts in one LLM request.
Returns {"emotion": {...}, "profile": {"facts": [...], ...}}.
Falls back to separate calls if the batched response can't be parsed.
"""
prompt = EMOTION_AND_PROFILE_PROMPT.format(
answer=answer_text,
context=context,
existing_summary=(
f"\nAlready known about this person: {existing_summary}"
if existing_summary else ""
),
)
try:
response = self.client.chat_completion(
messages=[
{"role": "system", "content": CLASSIFIER_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
temperature=0.05,
max_tokens=600,
response_format={"type": "json_object"},
)
data = json.loads(response)
# Validate emotion portion
emotion = self._validate_and_repair(
data.get("emotion", {}), self.DEFAULT_EMOTION_RESULT
)
emotion["valence_idx"] = self.VALENCE_INDEX_MAP.get(
emotion.get("valence", "neutral"), 2
)
emotion["arousal_idx"] = self.AROUSAL_INDEX_MAP.get(
emotion.get("arousal", "moderate"), 2
)
# Profile portion — pass through as-is for UserProfile to parse
profile = data.get("profile", {})
if not isinstance(profile, dict):
profile = {"facts": [], "causal_links": [], "progress_signals": []}
return {"emotion": emotion, "profile": profile}
except Exception as e:
logger.warning(
f"[SphereClassifier] Batched call failed ({type(e).__name__}), "
"falling back to separate emotion classification"
)
# Fall back to emotion-only (profile extraction will be skipped)
emotion = self.classify_emotion(answer_text, context)
return {
"emotion": emotion,
"profile": {"facts": [], "causal_links": [], "progress_signals": []},
}
def classify_emotion_heuristic(
self,
answer_text: str,
) -> Dict[str, Any]:
"""
Heuristic emotion classification — fallback when LLM is unavailable.
Uses keyword matching to estimate valence and arousal.
Less accurate than LLM classification but always available.
"""
lower = answer_text.lower()
# Valence detection
negative_words = [
"stressed", "anxious", "worried", "frustrated", "stuck",
"hopeless", "lost", "confused", "scared", "tired",
"burned out", "overwhelmed", "sad", "angry", "annoyed",
"hate", "terrible", "awful", "bad", "depressed",
"bored", "boring", "meh", "ugh", "down", "horrible",
"pointless", "miserable", "give up", "sucks", "worst",
"painful", "dread", "fail", "broken", "nothing works",
]
positive_words = [
"good", "great", "happy", "excited", "curious",
"interesting", "love", "amazing", "better", "hopeful",
"motivated", "ready", "clear", "peaceful", "calm",
"relaxed", "grateful", "proud", "confident", "wonderful",
"fantastic", "awesome", "brilliant", "beautiful",
"progress", "getting it", "starting to",
]
neg_count = sum(1 for w in negative_words if w in lower)
pos_count = sum(1 for w in positive_words if w in lower)
if neg_count > pos_count + 1:
valence = "very_negative"
elif neg_count > pos_count:
valence = "negative"
elif pos_count > neg_count + 1:
valence = "very_positive"
elif pos_count > neg_count:
valence = "positive"
else:
valence = "neutral"
# Arousal detection
high_arousal_words = [
"!", "stressed", "anxious", "excited", "angry",
"can't believe", "urgent", "help", "panic", "scared",
"amazing", "overwhelmed", "furious", "thrilled",
"panicking", "intense", "?!", "!!",
]
low_arousal_words = [
"bored", "tired", "calm", "peaceful", "relaxed",
"meh", "whatever", "ok", "fine", "sleepy",
"hopeless", "pointless", "numb",
]
high_count = sum(1 for w in high_arousal_words if w in lower)
low_count = sum(1 for w in low_arousal_words if w in lower)
# Short messages suggest low arousal
if len(answer_text.strip()) < 10:
low_count += 1
if high_count > low_count + 1:
arousal = "very_high"
elif high_count > low_count:
arousal = "high"
elif low_count > high_count + 1:
arousal = "very_low"
elif low_count > high_count:
arousal = "low"
else:
arousal = "moderate"
# Map to emotion label
v_idx = self.VALENCE_INDEX_MAP[valence]
a_idx = self.AROUSAL_INDEX_MAP[arousal]
# Simple circumplex mapping from indices
if v_idx >= 3 and a_idx >= 3:
emotion = "excited"
elif v_idx >= 3 and a_idx <= 1:
emotion = "relaxed"
elif v_idx <= 1 and a_idx >= 3:
emotion = "angry"
elif v_idx <= 1 and a_idx <= 1:
emotion = "depressed"
elif v_idx >= 3:
emotion = "happy"
elif v_idx <= 1:
emotion = "sad"
elif a_idx >= 3:
emotion = "alert"
else:
emotion = "calm"
return {
"valence": valence,
"arousal": arousal,
"valence_idx": v_idx,
"arousal_idx": a_idx,
"primary_emotion": emotion,
"confidence": "medium",
"emotional_cues": [],
}
def _call_llm(self, user_prompt: str, default: Dict) -> Dict[str, Any]:
"""Call the LLM and parse JSON response, with fallback."""
try:
response = self.client.chat_completion(
messages=[
{"role": "system", "content": CLASSIFIER_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.05,
max_tokens=256,
response_format={"type": "json_object"},
)
return self._validate_and_repair(json.loads(response), default)
except Exception as e:
logger.warning(
f"[SphereClassifier] LLM call failed ({type(e).__name__}: {e}), using defaults"
)
return default.copy()
def _validate_and_repair(
self, result: Dict, default: Dict
) -> Dict[str, Any]:
"""Ensure result has all required keys, fill missing with defaults."""
repaired = default.copy()
repaired.update({k: v for k, v in result.items() if k in default})
return repaired