WalkXR-AI / Paul-Game-Jam-Phase-2.5 / src / nodes / call_llm.py
call_llm.py
Raw
"""Node that calls OpenAI LLM to generate NPC responses."""

import json
import re

from langchain_core.messages import AIMessage
from langchain_openai import ChatOpenAI

from src.schemas.state import AgentOutput, WalkState


def _get_llm():
    return ChatOpenAI(
        model="gpt-4o-mini",
        temperature=0.7,
        model_kwargs={"response_format": {"type": "json_object"}},
    )


def _extract_json(text: str) -> dict:
    """Try multiple strategies to extract JSON from LLM response."""

    text = text.strip()

    # 1) Strip code block wrappers
    if text.startswith("```"):
        text = re.sub(r"^```(?:json)?\s*", "", text)
        text = re.sub(r"\s*```$", "", text)
        text = text.strip()

    # 2) Try direct parse
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    # 3) Find first { ... } block
    match = re.search(r"\{.*\}", text, re.DOTALL)
    if match:
        try:
            return json.loads(match.group())
        except json.JSONDecodeError:
            pass

    return {}


def _clamp(value: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, value))


def call_llm(state: WalkState) -> dict:
    """Call LLM and parse the AgentOutput with extended fields."""

    messages = state.get("messages", [])

    try:
        result = _get_llm().invoke(messages)
        parsed = _extract_json(result.content)

        if not parsed.get("response"):
            parsed = {
                "response": result.content.strip(),
                "should_end_turn": False,
                "next_step": "continue",
                "emotion_assessment": {"valence": 0.0, "arousal": 0.0, "label": "neutral"},
                "observation": "",
                "empathy_quality": "neutral",
                "trust_shift": 0.0,
                "npc_internal_mood": "guarded",
            }

        # Validate and clamp numeric fields
        emotion = parsed.get("emotion_assessment", {})
        trust_shift = _clamp(float(parsed.get("trust_shift", 0.0)), -0.3, 0.3)

        empathy_quality = parsed.get("empathy_quality", "neutral")
        if empathy_quality not in ("hit", "miss", "neutral"):
            empathy_quality = "neutral"

        npc_mood = parsed.get("npc_internal_mood", "guarded")
        if npc_mood not in ("guarded", "cautious", "warming", "open", "withdrawn"):
            npc_mood = "guarded"

        agent_output: AgentOutput = {
            "response": parsed.get("response", ""),
            "should_end_turn": bool(parsed.get("should_end_turn", False)),
            "next_step": parsed.get("next_step", "continue"),
            "emotion_assessment": {
                "valence": _clamp(float(emotion.get("valence", 0.0)), -1.0, 1.0),
                "arousal": _clamp(float(emotion.get("arousal", 0.0)), 0.0, 1.0),
                "label": emotion.get("label", "neutral"),
            },
            "observation": parsed.get("observation", ""),
            "empathy_quality": empathy_quality,
            "trust_shift": trust_shift,
            "npc_internal_mood": npc_mood,
        }

        # Add AI message to history
        new_messages = list(messages) + [AIMessage(content=agent_output["response"])]

        return {
            "agent_output": agent_output,
            "messages": new_messages,
        }

    except Exception as e:
        return {"error": f"LLM call failed: {e}"}