"""Node that generates reflective feedback after conversation ends.""" from langchain_core.messages import HumanMessage, SystemMessage from langchain_openai import ChatOpenAI from src.resources.empathetic_reflection_lexicon import LEXICON from src.schemas.state import WalkState def _get_llm(): return ChatOpenAI(model="gpt-4o-mini", temperature=0.5) REFLECTION_PROMPT = """You are an empathetic communication coach specializing in trauma-informed conversations. The user just finished a role-play session where they practiced talking to a veteran struggling with PTSD. Based on the rich data below, provide warm, specific, and actionable feedback. ## Scenario {scenario_title}: {scenario_description} ## Session Statistics - Total turns: {turn_count} - Final confidence (James's trust): {final_confidence:.1%} - Final rapport score: {rapport_score:.1%} - NPC final mood: {npc_mood} - NPC final openness: {npc_openness:.1%} ## Empathy Performance - Empathetic moments (hits): {empathy_hits} - Insensitive moments (misses): {empathy_misses} - Empathy ratio: {empathy_ratio:.0%} ## Conversation Stage Progression {stage_progression} ## Emotion Changes (User) {emotion_summary} - Dominant emotion throughout: {dominant_emotion} ## Observed Behaviors {observations} ## End Condition {end_reason} ## Empathetic Expression Guide Reference these principles: {lexicon_sample} ## Feedback Structure Write your feedback with these sections: **What You Did Well**: Highlight 2-3 specific moments where the user showed genuine empathy. Reference their actual words or approach if visible from observations. **How James Responded**: Describe James's emotional arc — did he open up? Stay guarded? Withdraw? Connect this to the user's approach. Use the stage progression and NPC mood data. **Your Empathy Score**: Based on the {empathy_ratio:.0%} empathy ratio and the rapport score of {rapport_score:.1%}. Frame this encouragingly regardless of score. **Tips for Next Time**: Give 2-3 concrete, trauma-informed tips. Tailor these to what the user struggled with (based on observations and misses). **Final Thought**: A brief, warm closing that affirms the value of showing up and trying. Keep it under 250 words. Be direct but warm. No bullet points — use flowing paragraphs. """ def _determine_end_reason(state: WalkState) -> str: """Infer why the conversation ended for richer feedback.""" memory = state.get("memory", {}) agent_output = state.get("agent_output", {}) scenario = state.get("scenario", {}) turn_index = state.get("turn_index", 0) if agent_output.get("should_end_turn"): if memory.get("npc_mood") == "withdrawn": return "James withdrew from the conversation due to feeling pushed or misunderstood." return "The conversation reached a natural ending — James chose to wrap up." if turn_index >= scenario.get("max_turns", 10): rapport = memory.get("rapport_score", 0) if rapport >= 0.7: return "Time ran out, but a meaningful connection was established." elif rapport >= 0.4: return "Time ran out. James was starting to open up but needed more time." else: return "Time ran out. James remained guarded throughout." if memory.get("npc_mood") == "withdrawn": return "James shut down after feeling the conversation became insensitive or pushy." if memory.get("silence_count", 0) >= 3: return "The conversation ended due to user disengagement (repeated short responses)." if memory.get("rapport_score", 0) >= 0.85: return "A deep connection was achieved — the conversation concluded on a high note." return "The conversation ended." def reflection(state: WalkState) -> dict: """Generate reflection feedback based on full memory data.""" memory = state.get("memory", {}) scenario = state.get("scenario", {}) trajectory = memory.get("emotion_trajectory", []) # Emotion summary if trajectory: emotion_lines = [ f" Turn {e['turn']}: {e.get('label', '?')} " f"(valence={e.get('valence', 0):.2f}, arousal={e.get('arousal', 0):.2f})" for e in trajectory ] emotion_summary = "\n".join(emotion_lines) else: emotion_summary = "(No emotion data recorded)" # Observations observations = "\n".join( f"- Turn {i + 1}: {obs}" for i, obs in enumerate(memory.get("key_observations", [])) ) or "(No observations recorded)" # Stage progression stage_history = memory.get("stage_history", []) stage_progression = " → ".join(stage_history) if stage_history else "opening" # Lexicon samples lexicon_sample = "\n".join(f"- {expr}" for expr in LEXICON[:6]) # End reason end_reason = _determine_end_reason(state) prompt = REFLECTION_PROMPT.format( scenario_title=scenario.get("title", ""), scenario_description=scenario.get("description", ""), turn_count=state.get("turn_index", 0), final_confidence=memory.get("confidence_level", 0.5), rapport_score=memory.get("rapport_score", 0.0), npc_mood=memory.get("npc_mood", "guarded"), npc_openness=memory.get("npc_openness", 0.3), empathy_hits=memory.get("empathy_hits", 0), empathy_misses=memory.get("empathy_misses", 0), empathy_ratio=memory.get("empathy_ratio", 0.5), stage_progression=stage_progression, emotion_summary=emotion_summary, dominant_emotion=memory.get("dominant_emotion", "neutral"), observations=observations, end_reason=end_reason, lexicon_sample=lexicon_sample, ) messages = [ SystemMessage(content=prompt), HumanMessage(content="Please write the feedback."), ] result = _get_llm().invoke(messages) return {"reflection_result": result.content}