Spaces:
Sleeping
Sleeping
Mahault commited on
Commit ·
89bc1b1
1
Parent(s): a663d0f
Integrate emotional inference into EFE action selection
Browse filesThread Circumplex POMDP outputs (valence beliefs, prediction error)
into coaching readiness, lambda_epist, and EFE action selection so
the agent responds to emotional state rather than just keywords.
src/mindsphere/core/action_dispatcher.py
CHANGED
|
@@ -48,12 +48,14 @@ def compute_lambda_epist(
|
|
| 48 |
timestep: int,
|
| 49 |
tom_reliability: float,
|
| 50 |
beliefs: Optional[Dict[str, np.ndarray]] = None,
|
|
|
|
| 51 |
) -> float:
|
| 52 |
"""
|
| 53 |
Compute the epistemic drive weight, balancing exploration vs exploitation.
|
| 54 |
|
| 55 |
Starts high (explore the user) and decays toward pragmatic (propose actions).
|
| 56 |
-
Boosted when ToM reliability is low
|
|
|
|
| 57 |
"""
|
| 58 |
# Phase-dependent base value
|
| 59 |
phase_base = {
|
|
@@ -78,7 +80,13 @@ def compute_lambda_epist(
|
|
| 78 |
# Reliability discount: boost epistemic when ToM is unreliable
|
| 79 |
reliability_factor = 1.0 + max(0.0, 0.5 - tom_reliability)
|
| 80 |
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
return result
|
| 83 |
|
| 84 |
|
|
@@ -95,6 +103,8 @@ def select_coaching_action(
|
|
| 95 |
target_skill: Optional[str] = None,
|
| 96 |
current_intervention=None,
|
| 97 |
beta: float = 4.0,
|
|
|
|
|
|
|
| 98 |
) -> Tuple[int, str, Dict]:
|
| 99 |
"""
|
| 100 |
Full EFE-driven action selection with empathy blending.
|
|
@@ -110,6 +120,8 @@ def select_coaching_action(
|
|
| 110 |
target_skill: Currently targeted skill (optional)
|
| 111 |
current_intervention: Current intervention dict (optional)
|
| 112 |
beta: Inverse temperature for softmax
|
|
|
|
|
|
|
| 113 |
|
| 114 |
Returns:
|
| 115 |
(action_idx, action_name, efe_info_dict)
|
|
@@ -117,7 +129,8 @@ def select_coaching_action(
|
|
| 117 |
valid = VALID_ACTIONS.get(phase, [A_ASK_FREE, A_PROPOSE])
|
| 118 |
|
| 119 |
lambda_epist = compute_lambda_epist(
|
| 120 |
-
phase, timestep, tom_reliability, beliefs
|
|
|
|
| 121 |
)
|
| 122 |
|
| 123 |
# Determine which factors are relevant
|
|
@@ -171,6 +184,29 @@ def select_coaching_action(
|
|
| 171 |
action_probs = blended_probs
|
| 172 |
efe_values = blended_values
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
action_name = ACTION_NAMES[action_idx]
|
| 175 |
|
| 176 |
info = {
|
|
@@ -185,6 +221,8 @@ def select_coaching_action(
|
|
| 185 |
},
|
| 186 |
"lambda_epist": round(lambda_epist, 3),
|
| 187 |
"phase": phase,
|
|
|
|
|
|
|
| 188 |
}
|
| 189 |
|
| 190 |
logger.info(
|
|
|
|
| 48 |
timestep: int,
|
| 49 |
tom_reliability: float,
|
| 50 |
beliefs: Optional[Dict[str, np.ndarray]] = None,
|
| 51 |
+
emotion_prediction_error: float = 0.0,
|
| 52 |
) -> float:
|
| 53 |
"""
|
| 54 |
Compute the epistemic drive weight, balancing exploration vs exploitation.
|
| 55 |
|
| 56 |
Starts high (explore the user) and decays toward pragmatic (propose actions).
|
| 57 |
+
Boosted when ToM reliability is low, beliefs are uncertain, or emotional
|
| 58 |
+
prediction error is high (model is wrong about user's emotional state).
|
| 59 |
"""
|
| 60 |
# Phase-dependent base value
|
| 61 |
phase_base = {
|
|
|
|
| 80 |
# Reliability discount: boost epistemic when ToM is unreliable
|
| 81 |
reliability_factor = 1.0 + max(0.0, 0.5 - tom_reliability)
|
| 82 |
|
| 83 |
+
# Emotion prediction error boost: when our emotional model is wrong,
|
| 84 |
+
# increase exploration (ask questions, don't push interventions)
|
| 85 |
+
emotion_factor = 1.0
|
| 86 |
+
if emotion_prediction_error > 0.2:
|
| 87 |
+
emotion_factor = 1.0 + 0.8 * min(emotion_prediction_error, 1.0)
|
| 88 |
+
|
| 89 |
+
result = phase_base * temporal_factor * uncertainty_factor * reliability_factor * emotion_factor
|
| 90 |
return result
|
| 91 |
|
| 92 |
|
|
|
|
| 103 |
target_skill: Optional[str] = None,
|
| 104 |
current_intervention=None,
|
| 105 |
beta: float = 4.0,
|
| 106 |
+
emotion_prediction_error: float = 0.0,
|
| 107 |
+
emotion_valence_belief: Optional[np.ndarray] = None,
|
| 108 |
) -> Tuple[int, str, Dict]:
|
| 109 |
"""
|
| 110 |
Full EFE-driven action selection with empathy blending.
|
|
|
|
| 120 |
target_skill: Currently targeted skill (optional)
|
| 121 |
current_intervention: Current intervention dict (optional)
|
| 122 |
beta: Inverse temperature for softmax
|
| 123 |
+
emotion_prediction_error: Magnitude of emotional prediction error [0, ~1.4]
|
| 124 |
+
emotion_valence_belief: 5-element belief over valence states (optional)
|
| 125 |
|
| 126 |
Returns:
|
| 127 |
(action_idx, action_name, efe_info_dict)
|
|
|
|
| 129 |
valid = VALID_ACTIONS.get(phase, [A_ASK_FREE, A_PROPOSE])
|
| 130 |
|
| 131 |
lambda_epist = compute_lambda_epist(
|
| 132 |
+
phase, timestep, tom_reliability, beliefs,
|
| 133 |
+
emotion_prediction_error=emotion_prediction_error,
|
| 134 |
)
|
| 135 |
|
| 136 |
# Determine which factors are relevant
|
|
|
|
| 184 |
action_probs = blended_probs
|
| 185 |
efe_values = blended_values
|
| 186 |
|
| 187 |
+
# Step 3: Emotional valence penalty on intervention actions
|
| 188 |
+
# When the user is in a negative emotional state, penalize pushing
|
| 189 |
+
# coaching interventions — prefer softer actions (ask, safety_check)
|
| 190 |
+
valence_negative_mass = None
|
| 191 |
+
if emotion_valence_belief is not None and len(emotion_valence_belief) >= 5:
|
| 192 |
+
valence_negative_mass = float(emotion_valence_belief[0] + emotion_valence_belief[1])
|
| 193 |
+
if valence_negative_mass > 0.4:
|
| 194 |
+
penalty = 0.5 * valence_negative_mass
|
| 195 |
+
for i, v in enumerate(valid):
|
| 196 |
+
if v in intervention_actions:
|
| 197 |
+
efe_values[i] += penalty # Higher G = worse action
|
| 198 |
+
# Re-select with penalty applied
|
| 199 |
+
from .utils import softmax as _softmax
|
| 200 |
+
q_values = -efe_values
|
| 201 |
+
penalized_probs = _softmax(q_values, temperature=1.0 / max(beta, 0.01))
|
| 202 |
+
best_idx = int(np.argmax(penalized_probs))
|
| 203 |
+
action_idx = valid[best_idx]
|
| 204 |
+
action_probs = penalized_probs
|
| 205 |
+
logger.info(
|
| 206 |
+
f"[EFE] Emotional valence penalty applied (neg_mass={valence_negative_mass:.2f}, "
|
| 207 |
+
f"penalty={penalty:.2f})"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
action_name = ACTION_NAMES[action_idx]
|
| 211 |
|
| 212 |
info = {
|
|
|
|
| 221 |
},
|
| 222 |
"lambda_epist": round(lambda_epist, 3),
|
| 223 |
"phase": phase,
|
| 224 |
+
"emotion_prediction_error": round(emotion_prediction_error, 3),
|
| 225 |
+
"valence_negative_mass": round(valence_negative_mass, 3) if valence_negative_mass is not None else None,
|
| 226 |
}
|
| 227 |
|
| 228 |
logger.info(
|
src/mindsphere/core/agent.py
CHANGED
|
@@ -254,14 +254,15 @@ class CoachingAgent:
|
|
| 254 |
logger.warning(f"[LLM] Empty response — falling back to template (phase={self.phase})")
|
| 255 |
return result
|
| 256 |
|
| 257 |
-
def _assess_cognitive_load(self, user_message: str = "") -> Dict[str, Any]:
|
| 258 |
"""
|
| 259 |
-
Infer cognitive load from ToM dimensions + conversation signals.
|
| 260 |
|
| 261 |
Uses:
|
| 262 |
- ToM overwhelm_threshold (low = easily overwhelmed)
|
| 263 |
- Recent sentiment signals from conversation
|
| 264 |
- Message length and engagement patterns
|
|
|
|
| 265 |
"""
|
| 266 |
# ToM-based assessment
|
| 267 |
tom_type = self.tom.get_user_type_summary()
|
|
@@ -321,6 +322,27 @@ class CoachingAgent:
|
|
| 321 |
if len(user_message.strip()) < 10 and user_message.strip():
|
| 322 |
signals.append("low_effort")
|
| 323 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
# Track recent sentiments
|
| 325 |
self._recent_sentiments.append(
|
| 326 |
"disengaged" if "disengaged" in signals
|
|
@@ -344,6 +366,9 @@ class CoachingAgent:
|
|
| 344 |
elif "off_topic" in signals or "deflection" in signals:
|
| 345 |
load_level = "redirected"
|
| 346 |
coaching_readiness = "not_ready"
|
|
|
|
|
|
|
|
|
|
| 347 |
elif "engaged" in signals:
|
| 348 |
load_level = "optimal"
|
| 349 |
coaching_readiness = "ready"
|
|
@@ -965,8 +990,8 @@ class CoachingAgent:
|
|
| 965 |
# Run emotional inference
|
| 966 |
emotional_data = self._run_emotional_inference(user_text)
|
| 967 |
|
| 968 |
-
# Assess cognitive load / intent
|
| 969 |
-
cog_load = self._assess_cognitive_load(user_text)
|
| 970 |
|
| 971 |
# Update cognitive model
|
| 972 |
self._update_user_model_from_text(user_text)
|
|
@@ -999,6 +1024,9 @@ class CoachingAgent:
|
|
| 999 |
return result
|
| 1000 |
|
| 1001 |
# === EFE-driven action selection ===
|
|
|
|
|
|
|
|
|
|
| 1002 |
action_idx, action_name, efe_info = select_coaching_action(
|
| 1003 |
beliefs=self.beliefs,
|
| 1004 |
model=self.model,
|
|
@@ -1009,6 +1037,8 @@ class CoachingAgent:
|
|
| 1009 |
tom_filter=self.tom,
|
| 1010 |
target_skill=self.target_skill,
|
| 1011 |
current_intervention=self.current_intervention,
|
|
|
|
|
|
|
| 1012 |
)
|
| 1013 |
|
| 1014 |
# Only transition when EFE strongly favors it AND we've had some discussion
|
|
@@ -1350,7 +1380,7 @@ class CoachingAgent:
|
|
| 1350 |
"another step", "next step", "what else",
|
| 1351 |
"give me something", "another exercise", "what now",
|
| 1352 |
])
|
| 1353 |
-
cog_load = self._assess_cognitive_load(user_text)
|
| 1354 |
if wants_more_action and cog_load["coaching_readiness"] != "not_ready":
|
| 1355 |
self._track_conversation("user", user_text)
|
| 1356 |
result = self._propose_next_coaching_step()
|
|
@@ -1382,6 +1412,9 @@ class CoachingAgent:
|
|
| 1382 |
}
|
| 1383 |
|
| 1384 |
# === EFE-driven action selection ===
|
|
|
|
|
|
|
|
|
|
| 1385 |
action_idx, action_name, efe_info = select_coaching_action(
|
| 1386 |
beliefs=self.beliefs,
|
| 1387 |
model=self.model,
|
|
@@ -1392,6 +1425,8 @@ class CoachingAgent:
|
|
| 1392 |
tom_filter=self.tom,
|
| 1393 |
target_skill=self.target_skill,
|
| 1394 |
current_intervention=self.current_intervention,
|
|
|
|
|
|
|
| 1395 |
)
|
| 1396 |
|
| 1397 |
# Dispatch based on EFE-selected action
|
|
|
|
| 254 |
logger.warning(f"[LLM] Empty response — falling back to template (phase={self.phase})")
|
| 255 |
return result
|
| 256 |
|
| 257 |
+
def _assess_cognitive_load(self, user_message: str = "", emotional_data: Optional[Dict] = None) -> Dict[str, Any]:
|
| 258 |
"""
|
| 259 |
+
Infer cognitive load from ToM dimensions + conversation signals + emotional state.
|
| 260 |
|
| 261 |
Uses:
|
| 262 |
- ToM overwhelm_threshold (low = easily overwhelmed)
|
| 263 |
- Recent sentiment signals from conversation
|
| 264 |
- Message length and engagement patterns
|
| 265 |
+
- Emotional valence beliefs and prediction error from Circumplex POMDP
|
| 266 |
"""
|
| 267 |
# ToM-based assessment
|
| 268 |
tom_type = self.tom.get_user_type_summary()
|
|
|
|
| 322 |
if len(user_message.strip()) < 10 and user_message.strip():
|
| 323 |
signals.append("low_effort")
|
| 324 |
|
| 325 |
+
# === Emotional state integration (from Circumplex POMDP) ===
|
| 326 |
+
if emotional_data:
|
| 327 |
+
beliefs = emotional_data.get("emotional_beliefs", {})
|
| 328 |
+
valence_belief = beliefs.get("valence", {}).get("belief", [])
|
| 329 |
+
|
| 330 |
+
# Valence belief concentrated on negative states → emotional distress
|
| 331 |
+
if len(valence_belief) >= 5:
|
| 332 |
+
negative_mass = valence_belief[0] + valence_belief[1]
|
| 333 |
+
if negative_mass > 0.6:
|
| 334 |
+
signals.append("emotional_distress")
|
| 335 |
+
|
| 336 |
+
# High prediction error → our emotional model was wrong
|
| 337 |
+
error_data = emotional_data.get("error", {})
|
| 338 |
+
if error_data.get("magnitude", 0) > 0.5:
|
| 339 |
+
signals.append("emotional_surprise")
|
| 340 |
+
|
| 341 |
+
# Current valence strongly negative
|
| 342 |
+
current = emotional_data.get("current_emotion", {})
|
| 343 |
+
if current and current.get("valence", 0) < -0.3:
|
| 344 |
+
signals.append("low_valence")
|
| 345 |
+
|
| 346 |
# Track recent sentiments
|
| 347 |
self._recent_sentiments.append(
|
| 348 |
"disengaged" if "disengaged" in signals
|
|
|
|
| 366 |
elif "off_topic" in signals or "deflection" in signals:
|
| 367 |
load_level = "redirected"
|
| 368 |
coaching_readiness = "not_ready"
|
| 369 |
+
elif "emotional_distress" in signals or "low_valence" in signals:
|
| 370 |
+
load_level = "emotionally_vulnerable"
|
| 371 |
+
coaching_readiness = "not_ready"
|
| 372 |
elif "engaged" in signals:
|
| 373 |
load_level = "optimal"
|
| 374 |
coaching_readiness = "ready"
|
|
|
|
| 990 |
# Run emotional inference
|
| 991 |
emotional_data = self._run_emotional_inference(user_text)
|
| 992 |
|
| 993 |
+
# Assess cognitive load / intent (with emotional data)
|
| 994 |
+
cog_load = self._assess_cognitive_load(user_text, emotional_data=emotional_data)
|
| 995 |
|
| 996 |
# Update cognitive model
|
| 997 |
self._update_user_model_from_text(user_text)
|
|
|
|
| 1024 |
return result
|
| 1025 |
|
| 1026 |
# === EFE-driven action selection ===
|
| 1027 |
+
emotion_error_mag = emotional_data.get("error", {}).get("magnitude", 0.0)
|
| 1028 |
+
valence_belief = self.emotion.belief_valence
|
| 1029 |
+
|
| 1030 |
action_idx, action_name, efe_info = select_coaching_action(
|
| 1031 |
beliefs=self.beliefs,
|
| 1032 |
model=self.model,
|
|
|
|
| 1037 |
tom_filter=self.tom,
|
| 1038 |
target_skill=self.target_skill,
|
| 1039 |
current_intervention=self.current_intervention,
|
| 1040 |
+
emotion_prediction_error=emotion_error_mag,
|
| 1041 |
+
emotion_valence_belief=valence_belief,
|
| 1042 |
)
|
| 1043 |
|
| 1044 |
# Only transition when EFE strongly favors it AND we've had some discussion
|
|
|
|
| 1380 |
"another step", "next step", "what else",
|
| 1381 |
"give me something", "another exercise", "what now",
|
| 1382 |
])
|
| 1383 |
+
cog_load = self._assess_cognitive_load(user_text, emotional_data=emotional_data)
|
| 1384 |
if wants_more_action and cog_load["coaching_readiness"] != "not_ready":
|
| 1385 |
self._track_conversation("user", user_text)
|
| 1386 |
result = self._propose_next_coaching_step()
|
|
|
|
| 1412 |
}
|
| 1413 |
|
| 1414 |
# === EFE-driven action selection ===
|
| 1415 |
+
emotion_error_mag = emotional_data.get("error", {}).get("magnitude", 0.0)
|
| 1416 |
+
valence_belief = self.emotion.belief_valence
|
| 1417 |
+
|
| 1418 |
action_idx, action_name, efe_info = select_coaching_action(
|
| 1419 |
beliefs=self.beliefs,
|
| 1420 |
model=self.model,
|
|
|
|
| 1425 |
tom_filter=self.tom,
|
| 1426 |
target_skill=self.target_skill,
|
| 1427 |
current_intervention=self.current_intervention,
|
| 1428 |
+
emotion_prediction_error=emotion_error_mag,
|
| 1429 |
+
emotion_valence_belief=valence_belief,
|
| 1430 |
)
|
| 1431 |
|
| 1432 |
# Dispatch based on EFE-selected action
|