diff --git a/README.md b/README.md index f798873..618df5c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ If you need to explain this app in detail, use this mental model: profile through an AI-coach reflection loop (`POST /discovery/profile/me/reflect`) — the coach mirrors the profile back and applies the person's own corrections — before affirming with the same confirm/lock. +13. After affirming (Phase 3) the user opens `/static/coaching.html` to tune + coaching preferences (auto-derived from their profile) and receive periodic + check-ins that quote their own words and ask if their direction still holds. + A weekly cron calls `POST /discovery/coaching/run` to generate due check-ins. Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and authenticate with `X-API-Key: $IMPACTFLOW_API_KEY` instead. That header @@ -142,16 +146,22 @@ Important files: | `app/static/discovery.html` | Browser-based seven-prompt flow | | `app/static/profile.html` | Browser-based profile display, edit, and confirm actions; links to reflection | | `app/static/reflect.html` | Phase 2 AI-coach reflection chat (mirror loop, applies revisions, affirm) | +| `app/static/coaching.html` | Phase 3 coaching preferences form + check-in feed | | `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login | | `app/static/style.css` | Shared UI styling | | `app/services/reflector.py` | `ReflectionCoach`: Anthropic-backed mirror loop, JSON parsing, revision filtering | +| `app/services/coaching.py` | Deterministic preference generator + `CheckinCoach` (Anthropic check-in text) | +| `app/routers/coaching.py` | Phase 3 coaching routes: preferences, check-ins, weekly batch `/run` | | `alembic/versions/001_initial.py` | Initial database schema migration | | `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables | | `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` | | `alembic/versions/004_add_reflection.py` | Adds `reflection_message` and `profile_revision` tables (Phase 2) | +| `alembic/versions/005_add_coaching.py` | Adds `coaching_preferences` and `coaching_checkin` tables (Phase 3) | | `tests/conftest.py` | Shared `app_client` fixture (isolated app + temp DB) | | `tests/test_extractor.py` | Unit tests for extraction plumbing, goals, and retry behavior | | `tests/test_reflector.py` | Unit tests for `ReflectionCoach` (mirror, revision filtering, retry) | +| `tests/test_coaching_prefs.py` | Unit tests for the deterministic coaching-preference generator | +| `tests/test_coaching.py` | Tests for coaching endpoints (preferences, check-ins, due-logic batch) | | `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list | | `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) | | `tests/test_reflection.py` | Tests for the reflection endpoints (turns, applied revisions, lock/`409`, history) | @@ -386,6 +396,37 @@ still the `/confirm` lock above; the loop is what happens before it. history, newest first. Every change is snapshotted in `profile_revision` with a `source` of `extraction` (initial), `reflection`, or `manual_edit`. +### 9. Coaching Preferences & Check-ins (Phase 3) + +How the person wants to be coached, plus a periodic check-in engine. + +Coaching **preferences** are auto-generated from the profile's Enneagram centre +(a deterministic mapping — gut → direct/higher-friction, heart → warm/sensitive +to drift, head → reflective/question-led) and are fully overridable. Fields: +`coaching_frequency`, `coaching_style`, `misalignment_threshold`, +`friction_tolerance`, `prefer_questions_over_directives`, +`time_of_day_preference`. + +- `GET /discovery/coaching/preferences` returns the preferences, deriving + defaults from the latest profile on first access (`404` if no profile yet). +- `PUT /discovery/coaching/preferences` overrides any field (validated against + the allowed value sets) and marks them user-customized. +- `POST /discovery/coaching/preferences/regenerate` re-derives the defaults + from the latest profile, discarding overrides. + +A **check-in** 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. The person's answer is recorded in `still_valid`. + +- `POST /discovery/coaching/checkins` generates a check-in now (on demand). +- `GET /discovery/coaching/checkins` lists them, newest first. +- `PUT /discovery/coaching/checkins/{id}/respond` records the self-assessment + (`still_valid` + optional note). +- `POST /discovery/coaching/run` is the **weekly batch job** (admin-only, + intended for a cron): it generates a check-in for every eligible user whose + cadence is due. Eligible = coaching cadence not `off` and an affirmed + (locked) profile; due = no prior check-in or the cadence interval has elapsed. + ## API Reference All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes @@ -421,6 +462,13 @@ clients.) `/api/auth/login`, `/api/auth/callback`, `/health`, `/`, and | `GET` | `/discovery/profile/me/reflection` | yes | The reflection dialogue for the latest profile | | `GET` | `/discovery/profile/me/revisions` | yes | Profile edit/iteration history (newest first) | | `GET` | `/discovery/conversation/{conversation_id}` | yes | Fetch stored conversation responses (owner only) | +| `GET` | `/discovery/coaching/preferences` | yes | Coaching preferences (auto-derived on first access) | +| `PUT` | `/discovery/coaching/preferences` | yes | Override coaching preferences | +| `POST` | `/discovery/coaching/preferences/regenerate` | yes | Re-derive preference defaults from the profile | +| `GET` | `/discovery/coaching/checkins` | yes | List the user's check-ins (newest first) | +| `POST` | `/discovery/coaching/checkins` | yes | Generate a check-in now | +| `PUT` | `/discovery/coaching/checkins/{id}/respond` | yes | Record "is your direction still valid?" | +| `POST` | `/discovery/coaching/run` | admin | Weekly batch: generate due check-ins for eligible users | ## Data Model @@ -541,6 +589,39 @@ iterations are captured rather than overwritten (migration `004`). | `note` | text nullable | What changed (e.g. the coach's revision note) | | `created_at` | datetime | UTC | +### `coaching_preferences` + +How the person wants to be coached; one row per user (migration `005`). + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | string | UUID primary key | +| `user_id` | string | FK to `users.id`, unique, indexed | +| `profile_id` | string nullable | The profile the defaults were derived from | +| `coaching_frequency` | string | `weekly`, `biweekly`, `monthly`, or `off` | +| `coaching_style` | string | `direct`, `warm`, or `reflective` | +| `misalignment_threshold` | string | `low`, `medium`, or `high` | +| `friction_tolerance` | string | `low`, `medium`, or `high` | +| `prefer_questions_over_directives` | boolean | Lead with questions vs statements | +| `time_of_day_preference` | string | `morning`, `afternoon`, or `evening` | +| `auto_generated` | boolean | True until the user edits a field | +| `created_at` / `updated_at` | datetime | UTC | + +### `coaching_checkin` + +A periodic coaching check-in and the person's response (migration `005`). + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | string | UUID primary key | +| `user_id` | string | FK to `users.id`, indexed | +| `profile_id` | string | FK to `discovery_profile.id` | +| `body` | text | The check-in text (quotes the person's own words) | +| `created_at` | datetime | UTC, indexed | +| `still_valid` | boolean nullable | The person's answer: is their direction still valid? | +| `response_note` | text nullable | Optional note with their response | +| `acknowledged_at` | datetime nullable | When they responded | + ## Extraction Details `DiscoveryExtractor` is intentionally responsible for plumbing, not business @@ -626,6 +707,17 @@ callback), so the pages hold no tokens of their own. true it updates the summary and notes what changed - "This is me — affirm" locks the profile via the same confirm endpoint +`coaching.html` (Phase 3 coaching): + +- loads preferences via `GET /discovery/coaching/preferences` (auto-derived on + first visit) and renders the six fields as selects + a toggle; Save + (`PUT`) marks them customized, "Reset to suggested" re-derives from the + profile +- lists check-ins and can generate one on demand (`POST .../checkins`); each + unanswered check-in offers "still feels true" / "it's shifted" which posts to + `.../respond` +- linked from `profile.html` ("Coaching preferences & check-ins") + ## Configuration Populate `.env` with at minimum the Anthropic key and the auth-related diff --git a/alembic/versions/005_add_coaching.py b/alembic/versions/005_add_coaching.py new file mode 100644 index 0000000..899aad7 --- /dev/null +++ b/alembic/versions/005_add_coaching.py @@ -0,0 +1,106 @@ +"""add coaching_preferences and coaching_checkin (Phase 3) + +Revision ID: 005 +Revises: 004 +Create Date: 2026-06-16 + +Phase 3: coaching preferences (auto-generated from the profile, user-overridable) +and periodic coaching check-ins that quote the person's own words and ask +whether their direction still feels valid. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "005" +down_revision: Union[str, None] = "004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "coaching_preferences", + sa.Column("id", sa.String(), primary_key=True), + sa.Column( + "user_id", + sa.String(), + sa.ForeignKey("users.id"), + nullable=False, + unique=True, + ), + sa.Column( + "profile_id", + sa.String(), + sa.ForeignKey("discovery_profile.id"), + nullable=True, + ), + sa.Column("coaching_frequency", sa.String(), nullable=False), + sa.Column("coaching_style", sa.String(), nullable=False), + sa.Column("misalignment_threshold", sa.String(), nullable=False), + sa.Column("friction_tolerance", sa.String(), nullable=False), + sa.Column( + "prefer_questions_over_directives", + sa.Boolean(), + nullable=False, + server_default=sa.text("1"), + ), + sa.Column("time_of_day_preference", sa.String(), nullable=False), + sa.Column( + "auto_generated", + sa.Boolean(), + nullable=False, + server_default=sa.text("1"), + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index( + "ix_coaching_preferences_user_id", + "coaching_preferences", + ["user_id"], + ) + + op.create_table( + "coaching_checkin", + sa.Column("id", sa.String(), primary_key=True), + sa.Column( + "user_id", + sa.String(), + sa.ForeignKey("users.id"), + nullable=False, + ), + sa.Column( + "profile_id", + sa.String(), + sa.ForeignKey("discovery_profile.id"), + nullable=False, + ), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("still_valid", sa.Boolean(), nullable=True), + sa.Column("response_note", sa.Text(), nullable=True), + sa.Column("acknowledged_at", sa.DateTime(), nullable=True), + ) + op.create_index( + "ix_coaching_checkin_user_id", "coaching_checkin", ["user_id"] + ) + op.create_index( + "ix_coaching_checkin_created_at", "coaching_checkin", ["created_at"] + ) + + +def downgrade() -> None: + op.drop_index( + "ix_coaching_checkin_created_at", table_name="coaching_checkin" + ) + op.drop_index( + "ix_coaching_checkin_user_id", table_name="coaching_checkin" + ) + op.drop_table("coaching_checkin") + + op.drop_index( + "ix_coaching_preferences_user_id", table_name="coaching_preferences" + ) + op.drop_table("coaching_preferences") diff --git a/app/main.py b/app/main.py index 7da76c3..7d4f96d 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models.py b/app/models.py index 6ab2997..47d13a4 100644 --- a/app/models.py +++ b/app/models.py @@ -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 + ) diff --git a/app/routers/coaching.py b/app/routers/coaching.py new file mode 100644 index 0000000..fe605cf --- /dev/null +++ b/app/routers/coaching.py @@ -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 + ) diff --git a/app/schemas.py b/app/schemas.py index 850c7e9..f171711 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 diff --git a/app/services/coaching.py b/app/services/coaching.py new file mode 100644 index 0000000..4772b79 --- /dev/null +++ b/app/services/coaching.py @@ -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 diff --git a/app/static/coaching.html b/app/static/coaching.html new file mode 100644 index 0000000..43f06a9 --- /dev/null +++ b/app/static/coaching.html @@ -0,0 +1,238 @@ + + +
+ + +Loading your preferences…
Check-ins
+Loading…
+ Coaching preferences & check-ins → +
`; + content.innerHTML = `