mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 09:50:36 +00:00
c4fc1cccd7
Build the boundary the ImpactFlow core time-tracker plugs into. A task maps to a foundation — one of the six stable profile elements (love, strength, mission, vocation, short_term, long_term) — so the tracker can ask "which goal does this build toward?" and post the answer back to Vision. - Models + migration 006: task_mapping (one row per logged time entry). - app/services/foundations.py: the six foundations + a pure, testable work-pattern aggregator (rollup) and a plain-language summary. - app/routers/integration.py (user-scoped; tracker calls as the user or via X-API-Key): GET /foundations, POST/GET /task-mappings, GET /work-patterns?days=N (per-foundation minutes/share/neglected). - Reminder engine now pulls from real work patterns: CheckinCoach takes an optional work-pattern summary (last 14 days) and reflects where time has gone against the person's own words — an observation, never a verdict. - Frontend: dashboard.html (time per foundation + neglected); linked from profile.html. Documented the core-tracker integration contract in the README. Phase 4 completes the Vision module's roadmap on the Discovery side; the core tracker integrates by calling these endpoints. Tests: 86 passing (added pure-aggregator, integration-endpoint, and work-pattern-into-check-in tests; run in-container). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
7.3 KiB
Python
183 lines
7.3 KiB
Python
"""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
|