"""Phase 3 coaching: preference generation + the weekly check-in coach. Two distinct pieces: - ``generate_preferences`` is DETERMINISTIC. Coaching preferences are a structural read of the person's Enneagram centre, so they are derived from a documented mapping (no LLM, fully testable). The user can override any field. - ``CheckinCoach`` is the LLM piece. It writes a periodic check-in that quotes the person's OWN words and asks whether their stated direction still feels valid. Mirror, not compass: it asks, it never judges or prescribes. """ from typing import Any, Dict from anthropic import AsyncAnthropic DEFAULT_MODEL = "claude-sonnet-4-6" MAX_TOKENS = 600 # Allowed values for each preference field (validated at the API boundary). ALLOWED = { "coaching_frequency": {"weekly", "biweekly", "monthly", "off"}, "coaching_style": {"direct", "warm", "reflective"}, "misalignment_threshold": {"low", "medium", "high"}, "friction_tolerance": {"low", "medium", "high"}, "time_of_day_preference": {"morning", "afternoon", "evening"}, } # Per-triad defaults. Rationale: # gut — acts from instinct; wants it direct, tolerates friction, fewer # questions, a weekly nudge in the morning. # heart— navigates by feeling/connection; wants warmth, is sensitive to # drift (low threshold), prefers questions. # head — thinks before committing; wants reflective space, low friction # tolerance, questions over directives, a slower (biweekly) cadence. _TRIAD_DEFAULTS = { "gut": { "coaching_frequency": "weekly", "coaching_style": "direct", "misalignment_threshold": "medium", "friction_tolerance": "high", "prefer_questions_over_directives": False, "time_of_day_preference": "morning", }, "heart": { "coaching_frequency": "weekly", "coaching_style": "warm", "misalignment_threshold": "low", "friction_tolerance": "medium", "prefer_questions_over_directives": True, "time_of_day_preference": "morning", }, "head": { "coaching_frequency": "biweekly", "coaching_style": "reflective", "misalignment_threshold": "medium", "friction_tolerance": "low", "prefer_questions_over_directives": True, "time_of_day_preference": "evening", }, } # Used when the triad is missing/unknown: a gentle, question-led default that # is consistent with mirror-not-compass. _FALLBACK_DEFAULTS = { "coaching_frequency": "weekly", "coaching_style": "warm", "misalignment_threshold": "medium", "friction_tolerance": "medium", "prefer_questions_over_directives": True, "time_of_day_preference": "morning", } def generate_preferences(profile: Dict[str, Any]) -> Dict[str, Any]: """Derive default coaching preferences from a profile's Enneagram centre. Args: profile: at least ``{"triad": "gut"|"heart"|"head"|None}``. Returns: A dict with all six preference fields. """ triad = (profile.get("triad") or "").lower() return dict(_TRIAD_DEFAULTS.get(triad, _FALLBACK_DEFAULTS)) class CheckinError(Exception): """Raised when check-in generation fails (API error or empty output).""" SYSTEM_PROMPT = """You are an AI coach writing a brief, periodic check-in for a person using a self-discovery tool. You are a MIRROR, never a compass. THE PERSON'S OWN WORDS (their profile): {profile} HOW THEY WANT TO BE COACHED: {prefs} RECENT WORK PATTERNS (from their time tracker, may be empty): {work_patterns} YOUR TASK: - Write a short check-in (3-5 sentences) that QUOTES the person's own words back to them — a specific phrase from their goals or their sense of purpose, in quotation marks. - If recent work patterns are given, you may gently reflect what they show (e.g. where their time has and hasn't gone) — but only as an observation to check against their own words. Never tell them it is good or bad. - Then ask, gently and openly, whether that direction still feels true for them right now. Invite them to say if anything has shifted. ABSOLUTE RULES (mirror, not compass): - NEVER tell them what to do, whether they are on or off track, or what they "should" pursue. You ASK; you do not judge. - NEVER invent goals or direction they did not state. Only reflect their own words. - Do not mention Enneagram type numbers. - Match their preferred style ({style}). If they prefer questions over directives ({prefer_questions}), lead with a question rather than a statement. - Plain language, second person (you/your). No preamble, no sign-off, no markdown — just the check-in text.""" class CheckinCoach: """Generates the text of a single coaching check-in.""" def __init__(self, api_key: str, model: str = DEFAULT_MODEL): if not api_key: raise CheckinError( "ANTHROPIC_API_KEY is not set; cannot generate a check-in." ) self.model = model self.client = AsyncAnthropic(api_key=api_key) @staticmethod def _profile_block(profile: Dict[str, Any]) -> str: lines = [ f"- What you love: {profile.get('love_summary') or '(none)'}", f"- What the world needs from you: {profile.get('mission_summary') or '(none)'}", f"- Where it converges: {profile.get('overlap_narrative') or '(none)'}", f"- Near-term goals: {profile.get('short_term_goals') or '(none stated)'}", f"- Long-term goals: {profile.get('long_term_goals') or '(none stated)'}", ] return "\n".join(lines) @staticmethod def _prefs_block(prefs: Dict[str, Any]) -> str: return ( f"- style: {prefs.get('coaching_style')}\n" f"- prefers questions over directives: {prefs.get('prefer_questions_over_directives')}\n" f"- friction tolerance: {prefs.get('friction_tolerance')}" ) async def generate( self, profile: Dict[str, Any], prefs: Dict[str, Any], work_patterns: str | None = None, ) -> str: """Produce the check-in body text. Raises CheckinError on failure. ``work_patterns`` is an optional plain-language summary of recent logged work (Phase 4); when present the coach may reflect it back as an observation to check against the person's own words.""" system = SYSTEM_PROMPT.format( profile=self._profile_block(profile), prefs=self._prefs_block(prefs), work_patterns=work_patterns or "(no recent work logged)", style=prefs.get("coaching_style", "warm"), prefer_questions=prefs.get("prefer_questions_over_directives", True), ) try: response = await self.client.messages.create( model=self.model, max_tokens=MAX_TOKENS, system=system, messages=[ { "role": "user", "content": "Write my check-in for this week.", } ], ) text = response.content[0].text.strip() except Exception as exc: # noqa: BLE001 - surface any SDK/transport error raise CheckinError(f"Anthropic API call failed: {exc}") from exc if not text: raise CheckinError("Model returned an empty check-in.") return text