"""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}"}