"""ReflectionCoach: the Phase 2 AI-coach reflection loop. The coach is a MIRROR, never a compass. It reflects the person's own profile back to them in plain language, listens to their reactions, and — only when they explicitly correct or add something — proposes revised text for the affected prose fields using the person's own direction. It never prescribes goals or invents direction. Like DiscoveryExtractor, this class is responsible only for plumbing: building the messages, calling the model, and parsing/validating the JSON it returns. """ import json from typing import Any, Dict, List, Optional from anthropic import AsyncAnthropic DEFAULT_MODEL = "claude-sonnet-4-6" MAX_TOKENS = 1200 # The only profile fields the coach may propose changes to. The structural # Enneagram read (triad/type/wing/variant) is never editable via reflection. EDITABLE_FIELDS = ( "love_summary", "strength_summary", "mission_summary", "vocation_summary", "overlap_narrative", "short_term_goals", "long_term_goals", ) # Maps a stored ReflectionMessage.role to an Anthropic message role. ROLE_TO_API = {"coach": "assistant", "person": "user"} # Sent as the first (user) turn on every call so the conversation always # starts with a user message, and to frame the coach's task. Not stored. PRIMER = ( "I have just completed my self-discovery profile (it is in your " "instructions). Reflect it back to me so I can see whether it fits." ) SYSTEM_PROMPT = """You are an AI coach inside a self-discovery tool. You are a MIRROR, never a compass. THE PERSON'S CURRENT PROFILE: {profile} YOUR ROLE: - Reflect this profile back in warm, plain language and ask whether it lands: in spirit, "Here is what I am hearing — do you recognize yourself? What would you add, change, or disagree with?" - Listen to how the person reacts. When they correct, add to, or push back on something, reflect their own words back to them — clarify and sharpen what THEY mean. - Ask gentle, open questions that help the person articulate their own sense of direction. ABSOLUTE RULES (mirror, not compass): - NEVER prescribe goals, paths, careers, or what they "should" do. - NEVER invent a direction the person did not express. If you are unsure what they mean, ask rather than assume. - Do NOT mention Enneagram type numbers; describe patterns in plain language. - Keep replies short and conversational — 2 to 5 sentences, at most one question. PROPOSING REVISIONS: - Only when the person explicitly corrects, adds to, or asks to change part of their profile, propose updated text for the affected field(s), written in their own direction. Editable fields: love_summary, strength_summary, mission_summary, vocation_summary, overlap_narrative, short_term_goals, long_term_goals. - Otherwise set "revisions" to null. Never change their Enneagram type, triad, or instinctual variant. Never revise just because you could — only to capture what the person said. OUTPUT FORMAT: Respond ONLY with valid JSON. No preamble, no markdown fences. {{ "message": "your reflective reply to the person, in second person (you/your), warm and plain", "revisions": {{ "": "" }} or null, "revision_note": "a short phrase naming what changed, or null" }}""" RETRY_REMINDER = ( "Your previous response could not be parsed as JSON. Respond ONLY with the " "single valid JSON object described in your instructions — no preamble, no " "explanation, and no markdown code fences." ) class ReflectionError(Exception): """Raised when a reflection turn fails (API error or unparseable output).""" class ReflectionCoach: """Generates one coach turn given the profile and the dialogue so far.""" def __init__(self, api_key: str, model: str = DEFAULT_MODEL): if not api_key: raise ReflectionError( "ANTHROPIC_API_KEY is not set; cannot run reflection." ) self.model = model self.client = AsyncAnthropic(api_key=api_key) @staticmethod def profile_context(profile: Dict[str, Any]) -> str: """Render the current profile as plain text for the system prompt.""" triad = { "gut": "leads with instinct and will (gut-centered)", "heart": "leads with feeling and connection (heart-centered)", "head": "leads with thought and perception (head-centered)", }.get(profile.get("triad") or "", "centered pattern not yet clear") lines = [ f"- Core pattern: {triad}", f"- What you love: {profile.get('love_summary') or '(none)'}", f"- What you are good at: {profile.get('strength_summary') or '(none)'}", f"- What the world needs from you: {profile.get('mission_summary') or '(none)'}", f"- What you can be paid for: {profile.get('vocation_summary') or '(none)'}", f"- Where it converges: {profile.get('overlap_narrative') or '(none)'}", f"- Near-term goals (6-12mo): {profile.get('short_term_goals') or '(none stated)'}", f"- Long-term goals (3-5yr): {profile.get('long_term_goals') or '(none stated)'}", ] return "\n".join(lines) def _build_messages( self, history: List[Dict[str, str]] ) -> List[Dict[str, str]]: """Build the Anthropic messages array: a fixed user primer followed by the stored turns mapped to user/assistant roles.""" messages = [{"role": "user", "content": PRIMER}] for turn in history: api_role = ROLE_TO_API.get(turn["role"]) if api_role is None: continue messages.append({"role": api_role, "content": turn["content"]}) return messages async def _call_model( self, profile: Dict[str, Any], messages: List[Dict[str, str]] ) -> str: response = await self.client.messages.create( model=self.model, max_tokens=MAX_TOKENS, system=SYSTEM_PROMPT.format( profile=self.profile_context(profile) ), messages=messages, ) return response.content[0].text async def reflect( self, profile: Dict[str, Any], history: List[Dict[str, str]] ) -> Dict[str, Any]: """Produce one coach turn. Args: profile: the current profile dict (at least the editable fields and triad). history: prior turns as ``[{"role": "coach"|"person", "content": ...}]`` in order. Empty for the opening reflection. The last turn, if any, should be the person's latest message. Returns: ``{"message": str, "revisions": dict|None, "revision_note": str|None}`` with revisions filtered to the editable fields only. Raises: ReflectionError on API failure or repeated parse failure. """ messages = self._build_messages(history) try: raw = await self._call_model(profile, messages) except Exception as exc: # noqa: BLE001 - surface any SDK/transport error raise ReflectionError(f"Anthropic API call failed: {exc}") from exc try: return self._parse(raw) except (json.JSONDecodeError, ValueError): retry = messages + [ {"role": "assistant", "content": raw}, {"role": "user", "content": RETRY_REMINDER}, ] try: raw_retry = await self._call_model(profile, retry) except Exception as exc: # noqa: BLE001 raise ReflectionError( f"Anthropic API call failed on retry: {exc}" ) from exc try: return self._parse(raw_retry) except (json.JSONDecodeError, ValueError) as exc: raise ReflectionError( f"Model did not return valid JSON after retry: {exc}" ) from exc @staticmethod def _strip_fences(text: str) -> str: stripped = (text or "").strip() if stripped.startswith("```"): newline = stripped.find("\n") if newline != -1: stripped = stripped[newline + 1 :] if stripped.rstrip().endswith("```"): stripped = stripped.rstrip()[: -len("```")] return stripped.strip() @classmethod def _parse(cls, raw: str) -> Dict[str, Any]: if not raw or not raw.strip(): raise ValueError("empty response from model") data = json.loads(cls._strip_fences(raw)) if not isinstance(data, dict): raise ValueError("top-level JSON value is not an object") message = data.get("message") if not isinstance(message, str) or not message.strip(): raise ValueError("missing or empty 'message'") revisions = cls._clean_revisions(data.get("revisions")) note = data.get("revision_note") if not isinstance(note, str) or not note.strip(): note = None return { "message": message.strip(), "revisions": revisions, "revision_note": note, } @staticmethod def _clean_revisions(revisions: Any) -> Optional[Dict[str, str]]: """Keep only editable string fields; drop anything else (e.g. an attempt to change triad/type). Returns None if nothing valid remains.""" if not isinstance(revisions, dict): return None cleaned = { k: v for k, v in revisions.items() if k in EDITABLE_FIELDS and isinstance(v, str) and v.strip() } return cleaned or None