mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:10:36 +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:
+7
-1
@@ -11,7 +11,12 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
from app import models # noqa: F401 - register ORM models with Base.metadata
|
||||
from app.auth import ensure_api_key_admin
|
||||
from app.database import AsyncSessionLocal, Base, engine
|
||||
from app.routers import activity, auth as auth_router, discovery
|
||||
from app.routers import (
|
||||
activity,
|
||||
auth as auth_router,
|
||||
coaching,
|
||||
discovery,
|
||||
)
|
||||
from app.routers.activity import prune_old_activity
|
||||
from app.tracking import ActivityTrackingMiddleware
|
||||
|
||||
@@ -64,6 +69,7 @@ app.add_middleware(
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(activity.router)
|
||||
app.include_router(discovery.router)
|
||||
app.include_router(coaching.router)
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
|
||||
@@ -188,3 +188,61 @@ class ProfileRevision(Base):
|
||||
fields_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class CoachingPreferences(Base):
|
||||
"""Phase 3: how this person wants to be coached. Auto-generated from their
|
||||
Enneagram/Ikigai profile, then overridable. One row per user."""
|
||||
|
||||
__tablename__ = "coaching_preferences"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), unique=True, nullable=False, index=True
|
||||
)
|
||||
# The profile the defaults were derived from (provenance).
|
||||
profile_id: Mapped[Optional[str]] = mapped_column(
|
||||
String, ForeignKey("discovery_profile.id"), nullable=True
|
||||
)
|
||||
|
||||
coaching_frequency: Mapped[str] = mapped_column(String, nullable=False)
|
||||
coaching_style: Mapped[str] = mapped_column(String, nullable=False)
|
||||
misalignment_threshold: Mapped[str] = mapped_column(String, nullable=False)
|
||||
friction_tolerance: Mapped[str] = mapped_column(String, nullable=False)
|
||||
prefer_questions_over_directives: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
time_of_day_preference: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
# True while still using auto-derived defaults; False once the user edits.
|
||||
auto_generated: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class CoachingCheckin(Base):
|
||||
"""Phase 3: a periodic coaching check-in. The body quotes the person's own
|
||||
words and asks whether their stated direction still feels valid (mirror,
|
||||
not compass). The person's answer is recorded in still_valid."""
|
||||
|
||||
__tablename__ = "coaching_checkin"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
profile_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("discovery_profile.id"), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, index=True
|
||||
)
|
||||
# The person's self-assessment: is their direction still valid?
|
||||
still_valid: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
|
||||
response_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, nullable=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Phase 3 coaching routes: preferences (auto-generated + overridable) and the
|
||||
weekly check-in engine.
|
||||
|
||||
All routes are user-scoped via the dual-auth dependency; the batch ``/run``
|
||||
trigger is admin-only (a cron calls it weekly).
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.auth import get_current_user, require_admin
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
CoachingCheckin,
|
||||
CoachingPreferences,
|
||||
DiscoveryProfile,
|
||||
User,
|
||||
)
|
||||
from app.services.coaching import (
|
||||
ALLOWED,
|
||||
CheckinCoach,
|
||||
CheckinError,
|
||||
generate_preferences,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/discovery/coaching", tags=["coaching"])
|
||||
|
||||
# How long between check-ins for each cadence; "off" means never.
|
||||
_FREQUENCY_DAYS = {"weekly": 7, "biweekly": 14, "monthly": 30, "off": None}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def _latest_profile(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> DiscoveryProfile | None:
|
||||
stmt = (
|
||||
select(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user_id)
|
||||
.order_by(DiscoveryProfile.generated_at.desc())
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
async def _get_preferences(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> CoachingPreferences | None:
|
||||
stmt = select(CoachingPreferences).where(
|
||||
CoachingPreferences.user_id == user_id
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
def _profile_dict(profile: DiscoveryProfile) -> dict:
|
||||
return {
|
||||
"triad": profile.triad,
|
||||
"love_summary": profile.love_summary,
|
||||
"strength_summary": profile.strength_summary,
|
||||
"mission_summary": profile.mission_summary,
|
||||
"vocation_summary": profile.vocation_summary,
|
||||
"overlap_narrative": profile.overlap_narrative,
|
||||
"short_term_goals": profile.short_term_goals,
|
||||
"long_term_goals": profile.long_term_goals,
|
||||
}
|
||||
|
||||
|
||||
def _prefs_out(p: CoachingPreferences) -> schemas.CoachingPreferencesOut:
|
||||
return schemas.CoachingPreferencesOut(
|
||||
coaching_frequency=p.coaching_frequency,
|
||||
coaching_style=p.coaching_style,
|
||||
misalignment_threshold=p.misalignment_threshold,
|
||||
friction_tolerance=p.friction_tolerance,
|
||||
prefer_questions_over_directives=p.prefer_questions_over_directives,
|
||||
time_of_day_preference=p.time_of_day_preference,
|
||||
auto_generated=p.auto_generated,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _checkin_out(c: CoachingCheckin) -> schemas.CheckinOut:
|
||||
return schemas.CheckinOut(
|
||||
id=c.id,
|
||||
body=c.body,
|
||||
created_at=c.created_at,
|
||||
still_valid=c.still_valid,
|
||||
response_note=c.response_note,
|
||||
acknowledged_at=c.acknowledged_at,
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_preferences(
|
||||
db: AsyncSession, user: User
|
||||
) -> CoachingPreferences:
|
||||
"""Return the user's preferences, deriving defaults from their latest
|
||||
profile on first access. 404 if they have no profile yet."""
|
||||
prefs = await _get_preferences(db, user.id)
|
||||
if prefs is not None:
|
||||
return prefs
|
||||
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No profile yet; complete self-discovery first.",
|
||||
)
|
||||
defaults = generate_preferences(_profile_dict(profile))
|
||||
now = _now()
|
||||
prefs = CoachingPreferences(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
profile_id=profile.id,
|
||||
auto_generated=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**defaults,
|
||||
)
|
||||
db.add(prefs)
|
||||
await db.commit()
|
||||
await db.refresh(prefs)
|
||||
return prefs
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=schemas.CoachingPreferencesOut)
|
||||
async def get_preferences(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return _prefs_out(await _ensure_preferences(db, user))
|
||||
|
||||
|
||||
@router.put("/preferences", response_model=schemas.CoachingPreferencesOut)
|
||||
async def update_preferences(
|
||||
payload: schemas.CoachingPreferencesUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
prefs = await _ensure_preferences(db, user)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
for field, value in updates.items():
|
||||
if field in ALLOWED and value not in ALLOWED[field]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid {field}: {value!r}. "
|
||||
f"Allowed: {sorted(ALLOWED[field])}",
|
||||
)
|
||||
setattr(prefs, field, value)
|
||||
prefs.auto_generated = False
|
||||
prefs.updated_at = _now()
|
||||
await db.commit()
|
||||
await db.refresh(prefs)
|
||||
return _prefs_out(prefs)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/preferences/regenerate",
|
||||
response_model=schemas.CoachingPreferencesOut,
|
||||
)
|
||||
async def regenerate_preferences(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Re-derive defaults from the latest profile, discarding overrides."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
prefs = await _ensure_preferences(db, user)
|
||||
for field, value in generate_preferences(_profile_dict(profile)).items():
|
||||
setattr(prefs, field, value)
|
||||
prefs.profile_id = profile.id
|
||||
prefs.auto_generated = True
|
||||
prefs.updated_at = _now()
|
||||
await db.commit()
|
||||
await db.refresh(prefs)
|
||||
return _prefs_out(prefs)
|
||||
|
||||
|
||||
@router.get("/checkins", response_model=list[schemas.CheckinOut])
|
||||
async def list_checkins(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
stmt = (
|
||||
select(CoachingCheckin)
|
||||
.where(CoachingCheckin.user_id == user.id)
|
||||
.order_by(CoachingCheckin.created_at.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [_checkin_out(c) for c in rows]
|
||||
|
||||
|
||||
async def _generate_checkin(
|
||||
db: AsyncSession, user_id: str, profile: DiscoveryProfile,
|
||||
prefs: CoachingPreferences,
|
||||
) -> CoachingCheckin:
|
||||
"""Generate and persist one check-in. Caller commits."""
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
coach = CheckinCoach(api_key=api_key, model=model)
|
||||
body = await coach.generate(
|
||||
_profile_dict(profile), _prefs_out(prefs).model_dump()
|
||||
)
|
||||
checkin = CoachingCheckin(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
profile_id=profile.id,
|
||||
body=body,
|
||||
created_at=_now(),
|
||||
)
|
||||
db.add(checkin)
|
||||
return checkin
|
||||
|
||||
|
||||
@router.post("/checkins", response_model=schemas.CheckinOut)
|
||||
async def create_checkin(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate a check-in now for the current user (on demand)."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
prefs = await _ensure_preferences(db, user)
|
||||
try:
|
||||
checkin = await _generate_checkin(db, user.id, profile, prefs)
|
||||
except CheckinError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await db.refresh(checkin)
|
||||
return _checkin_out(checkin)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/checkins/{checkin_id}/respond", response_model=schemas.CheckinOut
|
||||
)
|
||||
async def respond_to_checkin(
|
||||
checkin_id: str,
|
||||
payload: schemas.CheckinRespondRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Record the person's self-assessment: is their direction still valid?"""
|
||||
checkin = await db.get(CoachingCheckin, checkin_id)
|
||||
if checkin is None or (
|
||||
checkin.user_id != user.id and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Check-in not found")
|
||||
checkin.still_valid = payload.still_valid
|
||||
checkin.response_note = payload.note or None
|
||||
checkin.acknowledged_at = _now()
|
||||
await db.commit()
|
||||
await db.refresh(checkin)
|
||||
return _checkin_out(checkin)
|
||||
|
||||
|
||||
async def _last_checkin_at(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> datetime | None:
|
||||
stmt = (
|
||||
select(CoachingCheckin.created_at)
|
||||
.where(CoachingCheckin.user_id == user_id)
|
||||
.order_by(CoachingCheckin.created_at.desc())
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
def _is_due(frequency: str, last_at: datetime | None, now: datetime) -> bool:
|
||||
days = _FREQUENCY_DAYS.get(frequency)
|
||||
if days is None: # "off" or unknown cadence
|
||||
return False
|
||||
if last_at is None:
|
||||
return True
|
||||
if last_at.tzinfo is None:
|
||||
last_at = last_at.replace(tzinfo=timezone.utc)
|
||||
return now - last_at >= timedelta(days=days)
|
||||
|
||||
|
||||
@router.post("/run", response_model=schemas.RunCheckinsResponse)
|
||||
async def run_due_checkins(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
):
|
||||
"""The weekly batch job: generate a check-in for every eligible user whose
|
||||
cadence is due. Eligible = has coaching preferences (cadence != off) and an
|
||||
affirmed (locked) profile. Intended to be invoked by a weekly cron."""
|
||||
now = _now()
|
||||
prefs_rows = (
|
||||
await db.execute(select(CoachingPreferences))
|
||||
).scalars().all()
|
||||
|
||||
considered = 0
|
||||
generated = 0
|
||||
for prefs in prefs_rows:
|
||||
if _FREQUENCY_DAYS.get(prefs.coaching_frequency) is None:
|
||||
continue
|
||||
profile = await _latest_profile(db, prefs.user_id)
|
||||
if profile is None or not profile.locked:
|
||||
continue
|
||||
considered += 1
|
||||
last_at = await _last_checkin_at(db, prefs.user_id)
|
||||
if not _is_due(prefs.coaching_frequency, last_at, now):
|
||||
continue
|
||||
try:
|
||||
await _generate_checkin(db, prefs.user_id, profile, prefs)
|
||||
generated += 1
|
||||
except CheckinError:
|
||||
# Skip this user this run; a transient model error shouldn't abort
|
||||
# the whole batch.
|
||||
continue
|
||||
await db.commit()
|
||||
return schemas.RunCheckinsResponse(
|
||||
considered=considered, generated=generated
|
||||
)
|
||||
@@ -108,6 +108,48 @@ class ProfileRevisionOut(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# -- Phase 3: coaching preferences + check-ins -------------------------------
|
||||
|
||||
|
||||
class CoachingPreferencesOut(BaseModel):
|
||||
coaching_frequency: str
|
||||
coaching_style: str
|
||||
misalignment_threshold: str
|
||||
friction_tolerance: str
|
||||
prefer_questions_over_directives: bool
|
||||
time_of_day_preference: str
|
||||
auto_generated: bool
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CoachingPreferencesUpdate(BaseModel):
|
||||
coaching_frequency: Optional[str] = None
|
||||
coaching_style: Optional[str] = None
|
||||
misalignment_threshold: Optional[str] = None
|
||||
friction_tolerance: Optional[str] = None
|
||||
prefer_questions_over_directives: Optional[bool] = None
|
||||
time_of_day_preference: Optional[str] = None
|
||||
|
||||
|
||||
class CheckinOut(BaseModel):
|
||||
id: str
|
||||
body: str
|
||||
created_at: datetime
|
||||
still_valid: Optional[bool] = None
|
||||
response_note: Optional[str] = None
|
||||
acknowledged_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CheckinRespondRequest(BaseModel):
|
||||
still_valid: bool
|
||||
note: str = ""
|
||||
|
||||
|
||||
class RunCheckinsResponse(BaseModel):
|
||||
considered: int
|
||||
generated: int
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ImpactFlow — Coaching</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script src="/static/auth.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="brand">ImpactFlow · Coaching</div>
|
||||
<div id="prefs"><p>Loading your preferences…</p></div>
|
||||
<p class="section-label" style="margin-top:36px">Check-ins</p>
|
||||
<div id="checkins"><p>Loading…</p></div>
|
||||
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SELECTS = {
|
||||
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"],
|
||||
};
|
||||
const LABELS = {
|
||||
coaching_frequency: "How often should I check in?",
|
||||
coaching_style: "Coaching style",
|
||||
misalignment_threshold: "Flag drift when it's…",
|
||||
friction_tolerance: "Friction tolerance",
|
||||
time_of_day_preference: "Best time of day",
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function selectField(key, value) {
|
||||
const opts = SELECTS[key]
|
||||
.map(
|
||||
(o) =>
|
||||
`<option value="${o}"${o === value ? " selected" : ""}>${o}</option>`
|
||||
)
|
||||
.join("");
|
||||
return `<div class="edit-field">
|
||||
<label for="pref_${key}">${LABELS[key]}</label>
|
||||
<select id="pref_${key}" class="pref-select">${opts}</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrefs(p) {
|
||||
const fields = Object.keys(SELECTS).map((k) => selectField(k, p[k])).join("");
|
||||
const origin =
|
||||
p.auto_generated
|
||||
? "Auto-generated from your profile."
|
||||
: "Customized by you.";
|
||||
document.getElementById("prefs").innerHTML = `
|
||||
<p class="section-label">How you want to be coached</p>
|
||||
<p class="edit-help">${origin}</p>
|
||||
<div class="pref-grid">${fields}</div>
|
||||
<div class="edit-field" style="display:flex;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="pref_prefer_questions_over_directives" ${
|
||||
p.prefer_questions_over_directives ? "checked" : ""
|
||||
} />
|
||||
<label for="pref_prefer_questions_over_directives" style="margin:0">
|
||||
Prefer questions over directives
|
||||
</label>
|
||||
</div>
|
||||
<div class="nav">
|
||||
<button class="btn-ghost" id="regenBtn">Reset to suggested</button>
|
||||
<button class="btn-primary" id="saveBtn">Save preferences</button>
|
||||
</div>
|
||||
<div id="prefMsg"></div>`;
|
||||
document.getElementById("saveBtn").addEventListener("click", savePrefs);
|
||||
document.getElementById("regenBtn").addEventListener("click", regenPrefs);
|
||||
}
|
||||
|
||||
function collectPrefs() {
|
||||
const out = {};
|
||||
for (const k of Object.keys(SELECTS)) {
|
||||
out[k] = document.getElementById(`pref_${k}`).value;
|
||||
}
|
||||
out.prefer_questions_over_directives = document.getElementById(
|
||||
"pref_prefer_questions_over_directives"
|
||||
).checked;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function savePrefs() {
|
||||
const btn = document.getElementById("saveBtn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/preferences", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(collectPrefs()),
|
||||
});
|
||||
if (!res.ok) throw new Error("Could not save");
|
||||
renderPrefs(await res.json());
|
||||
note("prefMsg", "Saved.");
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
note("prefMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenPrefs() {
|
||||
try {
|
||||
const res = await authedFetch(
|
||||
"/discovery/coaching/preferences/regenerate",
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) throw new Error("Could not reset");
|
||||
renderPrefs(await res.json());
|
||||
note("prefMsg", "Reset to the profile's suggested defaults.");
|
||||
} catch (e) {
|
||||
note("prefMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function note(where, text, isError) {
|
||||
const el = document.getElementById(where);
|
||||
if (el)
|
||||
el.innerHTML = `<p class="${isError ? "error-box" : "note"}">${escapeHtml(
|
||||
text
|
||||
)}</p>`;
|
||||
}
|
||||
|
||||
function renderCheckins(list) {
|
||||
const items = list.length
|
||||
? list
|
||||
.map((c) => {
|
||||
const answered = c.acknowledged_at;
|
||||
const status = answered
|
||||
? `<p class="note">${
|
||||
c.still_valid
|
||||
? "You said this still feels true."
|
||||
: "You said this has shifted."
|
||||
}${c.response_note ? " — " + escapeHtml(c.response_note) : ""}</p>`
|
||||
: `<div class="nav" data-id="${c.id}">
|
||||
<button class="btn-ghost resp" data-v="false">It's shifted</button>
|
||||
<button class="btn-primary resp" data-v="true">Still feels true</button>
|
||||
</div>`;
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<p>${escapeHtml(c.body)}</p>
|
||||
${status}
|
||||
</div>`;
|
||||
})
|
||||
.join("")
|
||||
: `<p class="edit-help">No check-ins yet.</p>`;
|
||||
document.getElementById("checkins").innerHTML = `
|
||||
<div class="nav" style="justify-content:flex-end">
|
||||
<button class="btn-primary" id="genBtn">Check in with me now</button>
|
||||
</div>
|
||||
<div id="genMsg"></div>
|
||||
${items}`;
|
||||
document.getElementById("genBtn").addEventListener("click", generateNow);
|
||||
document.querySelectorAll(".resp").forEach((b) =>
|
||||
b.addEventListener("click", () => respond(b))
|
||||
);
|
||||
}
|
||||
|
||||
async function generateNow() {
|
||||
const btn = document.getElementById("genBtn");
|
||||
btn.disabled = true;
|
||||
note("genMsg", "Thinking…");
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/checkins", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({ detail: "Failed" }));
|
||||
throw new Error(d.detail || "Failed");
|
||||
}
|
||||
await loadCheckins();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
note("genMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function respond(btn) {
|
||||
const id = btn.closest(".nav").getAttribute("data-id");
|
||||
const stillValid = btn.getAttribute("data-v") === "true";
|
||||
try {
|
||||
const res = await authedFetch(
|
||||
`/discovery/coaching/checkins/${encodeURIComponent(id)}/respond`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ still_valid: stillValid }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error("Could not record");
|
||||
await loadCheckins();
|
||||
} catch (e) {
|
||||
note("genMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrefs() {
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/preferences");
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({ detail: "Failed" }));
|
||||
throw new Error(d.detail || "Failed");
|
||||
}
|
||||
renderPrefs(await res.json());
|
||||
} catch (e) {
|
||||
document.getElementById("prefs").innerHTML =
|
||||
`<div class="error-box">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCheckins() {
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/checkins");
|
||||
if (!res.ok) throw new Error("Could not load check-ins");
|
||||
renderCheckins(await res.json());
|
||||
} catch (e) {
|
||||
document.getElementById("checkins").innerHTML =
|
||||
`<div class="error-box">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
loadPrefs();
|
||||
loadCheckins();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -142,6 +142,11 @@
|
||||
<a href="/static/reflect.html">Not quite right? Talk it through with your coach →</a>
|
||||
</p>`;
|
||||
|
||||
const coachingLink =
|
||||
`<p class="back-link" style="text-align:center;margin-top:10px">
|
||||
<a href="/static/coaching.html">Coaching preferences & check-ins →</a>
|
||||
</p>`;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="profile-narrative">${escapeHtml(
|
||||
profile.overlap_narrative
|
||||
@@ -157,6 +162,7 @@
|
||||
|
||||
${goalsBlock}
|
||||
${actions}
|
||||
${coachingLink}
|
||||
`;
|
||||
|
||||
if (locked) return;
|
||||
|
||||
@@ -368,6 +368,36 @@ textarea.edit {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.pref-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pref-select {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid rgba(13, 27, 42, 0.18);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--navy);
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pref-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.pref-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user