WalkXR-AI / Paul-Game-Jam-Phase-2.5 / src / nodes / update_memory.py
update_memory.py
Raw
"""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,
    }