"""Node that updates conversation memory.
Tracks emotion trajectory, confidence, rapport, empathy quality,
NPC openness, conversation stage, and behavioral observations each turn."""
from collections import Counter
from src.schemas.state import UserMemory, WalkState
def _determine_stage(
turn_index: int,
max_turns: int,
should_end: bool,
confidence: float,
npc_openness: float,
) -> str:
"""Determine conversation stage based on multiple signals."""
if should_end:
return "closing"
# Forced closing near end
if turn_index >= max_turns - 1:
return "closing"
# Early turns are always opening
if turn_index <= 1:
return "opening"
# Dynamic stage based on rapport indicators
combined = (confidence + npc_openness) / 2
if combined >= 0.7:
return "deepening" # High trust — deeper conversation
elif combined >= 0.4:
return "developing" # Building rapport
else:
return "opening" # Still guarded — hasn't broken through yet
def _calculate_npc_openness(
prev_openness: float,
trust_shift: float,
empathy_quality: str,
npc_mood: str,
) -> float:
"""Calculate how open James is willing to be."""
delta = trust_shift * 0.5 # trust_shift directly influences openness
# Empathy quality bonus/penalty
if empathy_quality == "hit":
delta += 0.05
elif empathy_quality == "miss":
delta -= 0.1 # Misses hurt more than hits help
# If already withdrawn, harder to recover
if npc_mood == "withdrawn":
delta = min(delta, 0.02)
return max(0.0, min(1.0, prev_openness + delta))
def _determine_npc_mood(openness: float, empathy_quality: str, prev_mood: str) -> str:
"""Determine James's current emotional state."""
if empathy_quality == "miss" and prev_mood in ("guarded", "cautious"):
return "withdrawn"
if openness >= 0.75:
return "open"
elif openness >= 0.55:
return "warming"
elif openness >= 0.35:
return "cautious"
else:
return "guarded"
def _calculate_dominant_emotion(trajectory: list[dict]) -> str:
"""Find the most frequent emotion label across all turns."""
if not trajectory:
return "neutral"
labels = [e.get("label", "neutral") for e in trajectory]
counter = Counter(labels)
return counter.most_common(1)[0][0]
def update_memory(state: WalkState) -> dict:
"""Update memory based on agent_output — multi-dimensional tracking."""
agent_output = state.get("agent_output", {})
memory: UserMemory = dict(state.get("memory", {}))
turn_index = state.get("turn_index", 0)
scenario = state.get("scenario", {})
max_turns = scenario.get("max_turns", 10)
user_input = state.get("user_input", "")
# ── Emotion Trajectory ──────────────────────────────
emotion = agent_output.get("emotion_assessment", {})
trajectory = list(memory.get("emotion_trajectory", []))
trajectory.append(
{
"turn": turn_index,
"valence": emotion.get("valence", 0.0),
"arousal": emotion.get("arousal", 0.0),
"label": emotion.get("label", "neutral"),
}
)
memory["emotion_trajectory"] = trajectory
memory["dominant_emotion"] = _calculate_dominant_emotion(trajectory)
# ── Empathy Tracking ────────────────────────────────
empathy_quality = agent_output.get("empathy_quality", "neutral")
hits = memory.get("empathy_hits", 0)
misses = memory.get("empathy_misses", 0)
if empathy_quality == "hit":
hits += 1
elif empathy_quality == "miss":
misses += 1
memory["empathy_hits"] = hits
memory["empathy_misses"] = misses
total = hits + misses
memory["empathy_ratio"] = hits / total if total > 0 else 0.5
# ── Trust / Confidence ──────────────────────────────
trust_shift = agent_output.get("trust_shift", 0.0)
prev_confidence = memory.get("confidence_level", 0.5)
# Confidence is influenced by trust_shift + empathy ratio trend
confidence_delta = trust_shift * 0.3
if memory["empathy_ratio"] >= 0.7:
confidence_delta += 0.02 # Consistent empathy bonus
elif memory["empathy_ratio"] <= 0.3:
confidence_delta -= 0.03 # Consistent insensitivity penalty
memory["confidence_level"] = max(0.0, min(1.0, prev_confidence + confidence_delta))
# ── NPC Internal State ──────────────────────────────
prev_openness = memory.get("npc_openness", 0.3)
prev_mood = memory.get("npc_mood", "guarded")
npc_internal_mood = agent_output.get("npc_internal_mood", prev_mood)
memory["npc_openness"] = _calculate_npc_openness(
prev_openness, trust_shift, empathy_quality, prev_mood
)
memory["npc_mood"] = _determine_npc_mood(
memory["npc_openness"], empathy_quality, prev_mood
)
# ── Rapport Score (composite) ───────────────────────
memory["rapport_score"] = (
memory["confidence_level"] * 0.4
+ memory["npc_openness"] * 0.3
+ memory["empathy_ratio"] * 0.3
)
# ── Engagement Tracking ─────────────────────────────
input_len = len(user_input.strip())
prev_avg = memory.get("avg_response_length", 0.0)
memory["avg_response_length"] = (
(prev_avg * turn_index + input_len) / (turn_index + 1) if turn_index >= 0 else input_len
)
silence_count = memory.get("silence_count", 0)
if input_len < 5: # Very short response = possible disengagement
silence_count += 1
memory["silence_count"] = silence_count
# ── Conversation Stage ──────────────────────────────
should_end = agent_output.get("should_end_turn", False)
new_stage = _determine_stage(
turn_index, max_turns, should_end,
memory["confidence_level"], memory["npc_openness"],
)
memory["current_conversation_stage"] = new_stage
stage_history = list(memory.get("stage_history", []))
if not stage_history or stage_history[-1] != new_stage:
stage_history.append(new_stage)
memory["stage_history"] = stage_history
# ── Observations ────────────────────────────────────
observation = agent_output.get("observation", "")
observations = list(memory.get("key_observations", []))
if observation:
observations.append(observation)
memory["key_observations"] = observations
return {
"memory": memory,
"turn_index": turn_index + 1,
}