mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:40:37 +00:00
Phase 3: coaching preferences + weekly check-in engine
Add coaching preferences (auto-derived from the profile, user-overridable) and a periodic check-in engine that quotes the person's own words and asks whether their direction still feels valid — mirror, not compass. - Preferences are deterministic: a documented triad mapping (gut → direct/ higher-friction, heart → warm/drift-sensitive, head → reflective/question-led) produces defaults for the six fields (coaching_frequency, coaching_style, misalignment_threshold, friction_tolerance, prefer_questions_over_directives, time_of_day_preference). PUT overrides; regenerate re-derives. - CheckinCoach (app/services/coaching.py): Anthropic-backed; writes a check-in that quotes the person's goals back and asks if the direction still holds. - Endpoints (app/routers/coaching.py): GET/PUT/regenerate preferences; GET/POST checkins; respond (records still_valid); admin POST /run is the weekly batch (due = cadence elapsed + locked profile), intended for a cron. - Models + migration 005: coaching_preferences (per user) and coaching_checkin. - Frontend: coaching.html (preferences form + check-in feed); linked from profile.html. Tests: 68 passing (added deterministic-preference unit tests and coaching endpoint/batch tests; run in-container). README updated for Phase 3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""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}
|
||||
|
||||
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.
|
||||
- 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]
|
||||
) -> str:
|
||||
"""Produce the check-in body text. Raises CheckinError on failure."""
|
||||
system = SYSTEM_PROMPT.format(
|
||||
profile=self._profile_block(profile),
|
||||
prefs=self._prefs_block(prefs),
|
||||
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
|
||||
Reference in New Issue
Block a user