From 50453901b34a6402b2e97c66b4c67061016cc8e3 Mon Sep 17 00:00:00 2001
From: Joel Salmon
Date: Tue, 16 Jun 2026 21:10:54 -0500
Subject: [PATCH] Phase 3: coaching preferences + weekly check-in engine
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
README.md | 92 ++++++++
alembic/versions/005_add_coaching.py | 106 +++++++++
app/main.py | 8 +-
app/models.py | 58 +++++
app/routers/coaching.py | 322 +++++++++++++++++++++++++++
app/schemas.py | 42 ++++
app/services/coaching.py | 170 ++++++++++++++
app/static/coaching.html | 238 ++++++++++++++++++++
app/static/profile.html | 6 +
app/static/style.css | 30 +++
tests/test_coaching.py | 196 ++++++++++++++++
tests/test_coaching_prefs.py | 36 +++
tests/test_static_discovery.py | 14 ++
13 files changed, 1317 insertions(+), 1 deletion(-)
create mode 100644 alembic/versions/005_add_coaching.py
create mode 100644 app/routers/coaching.py
create mode 100644 app/services/coaching.py
create mode 100644 app/static/coaching.html
create mode 100644 tests/test_coaching.py
create mode 100644 tests/test_coaching_prefs.py
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 @@
+
+
+
+
+
+ ImpactFlow — Coaching
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/static/profile.html b/app/static/profile.html
index b5e75f8..e281cd5 100644
--- a/app/static/profile.html
+++ b/app/static/profile.html
@@ -142,6 +142,11 @@
Not quite right? Talk it through with your coach →
`;
+ const coachingLink =
+ `
+ Coaching preferences & check-ins →
+
`;
+
content.innerHTML = `
${escapeHtml(
profile.overlap_narrative
@@ -157,6 +162,7 @@
${goalsBlock}
${actions}
+ ${coachingLink}
`;
if (locked) return;
diff --git a/app/static/style.css b/app/static/style.css
index 67fb016..98ce9d1 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -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;
}
diff --git a/tests/test_coaching.py b/tests/test_coaching.py
new file mode 100644
index 0000000..2014c1e
--- /dev/null
+++ b/tests/test_coaching.py
@@ -0,0 +1,196 @@
+"""Tests for the Phase 3 coaching endpoints.
+
+The Anthropic-backed CheckinCoach is replaced with a fake (monkeypatch on the
+router) so these run offline. Auth uses the X-API-Key admin identity, which is
+also what lets the admin-only batch /run be exercised.
+"""
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+API_KEY = {"X-API-Key": "test-api-key"}
+
+
+async def _seed_profile(locked: bool = False, triad: str = "gut") -> str:
+ from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin
+ from app.database import AsyncSessionLocal
+ from app.models import DiscoveryConversation, DiscoveryProfile
+
+ async with AsyncSessionLocal() as db:
+ await ensure_api_key_admin(db)
+ conv = DiscoveryConversation(
+ id=str(uuid.uuid4()),
+ user_id=API_KEY_ADMIN_ID,
+ started_at=datetime.now(timezone.utc),
+ )
+ db.add(conv)
+ await db.commit()
+ profile = DiscoveryProfile(
+ id=str(uuid.uuid4()),
+ user_id=API_KEY_ADMIN_ID,
+ conversation_id=conv.id,
+ generated_at=datetime.now(timezone.utc),
+ triad=triad,
+ short_term_goals="run a pilot welding cohort",
+ long_term_goals="a statewide trades outfit",
+ overlap_narrative="you come alive protecting others",
+ locked=locked,
+ )
+ db.add(profile)
+ await db.commit()
+ return profile.id
+
+
+class FakeCheckinCoach:
+ body = 'You said you want to "run a pilot welding cohort." Does that still feel true?'
+
+ def __init__(self, api_key=None, model=None):
+ pass
+
+ async def generate(self, profile, prefs):
+ return FakeCheckinCoach.body
+
+
+@pytest.fixture(autouse=True)
+async def fake_checkin(monkeypatch, app_client):
+ monkeypatch.setattr("app.routers.coaching.CheckinCoach", FakeCheckinCoach)
+ yield
+
+
+async def test_get_preferences_autoderives_from_profile(app_client):
+ await _seed_profile(triad="gut")
+ r = await app_client.get(
+ "/discovery/coaching/preferences", headers=API_KEY
+ )
+ assert r.status_code == 200
+ p = r.json()
+ assert p["coaching_style"] == "direct" # gut default
+ assert p["auto_generated"] is True
+
+
+async def test_get_preferences_without_profile_404(app_client):
+ r = await app_client.get(
+ "/discovery/coaching/preferences", headers=API_KEY
+ )
+ assert r.status_code == 404
+
+
+async def test_put_preferences_overrides_and_clears_auto(app_client):
+ await _seed_profile(triad="gut")
+ await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
+ r = await app_client.put(
+ "/discovery/coaching/preferences",
+ headers=API_KEY,
+ json={"coaching_style": "warm", "coaching_frequency": "monthly"},
+ )
+ assert r.status_code == 200
+ p = r.json()
+ assert p["coaching_style"] == "warm"
+ assert p["coaching_frequency"] == "monthly"
+ assert p["auto_generated"] is False
+
+
+async def test_put_invalid_value_rejected(app_client):
+ await _seed_profile()
+ await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
+ r = await app_client.put(
+ "/discovery/coaching/preferences",
+ headers=API_KEY,
+ json={"coaching_frequency": "hourly"},
+ )
+ assert r.status_code == 400
+
+
+async def test_regenerate_restores_auto_defaults(app_client):
+ await _seed_profile(triad="gut")
+ await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
+ await app_client.put(
+ "/discovery/coaching/preferences",
+ headers=API_KEY,
+ json={"coaching_style": "warm"},
+ )
+ r = await app_client.post(
+ "/discovery/coaching/preferences/regenerate", headers=API_KEY
+ )
+ assert r.status_code == 200
+ p = r.json()
+ assert p["coaching_style"] == "direct" # back to gut default
+ assert p["auto_generated"] is True
+
+
+async def test_create_and_respond_to_checkin(app_client):
+ await _seed_profile()
+ gen = await app_client.post(
+ "/discovery/coaching/checkins", headers=API_KEY
+ )
+ assert gen.status_code == 200
+ checkin = gen.json()
+ assert "run a pilot" in checkin["body"]
+ assert checkin["still_valid"] is None
+
+ listed = (await app_client.get(
+ "/discovery/coaching/checkins", headers=API_KEY
+ )).json()
+ assert len(listed) == 1
+
+ resp = await app_client.put(
+ f"/discovery/coaching/checkins/{checkin['id']}/respond",
+ headers=API_KEY,
+ json={"still_valid": False, "note": "my focus shifted"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["still_valid"] is False
+ assert body["response_note"] == "my focus shifted"
+ assert body["acknowledged_at"] is not None
+
+
+async def test_run_batch_generates_for_due_locked_users(app_client):
+ await _seed_profile(locked=True, triad="gut")
+ # Auto-create preferences (weekly cadence for gut).
+ await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
+
+ # No prior check-in -> due -> one generated.
+ r1 = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
+ assert r1["considered"] == 1
+ assert r1["generated"] == 1
+
+ # Immediately again -> a recent check-in exists -> not due.
+ r2 = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
+ assert r2["generated"] == 0
+
+
+async def test_run_batch_skips_off_cadence(app_client):
+ await _seed_profile(locked=True)
+ await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
+ await app_client.put(
+ "/discovery/coaching/preferences",
+ headers=API_KEY,
+ json={"coaching_frequency": "off"},
+ )
+ r = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
+ assert r["considered"] == 0
+ assert r["generated"] == 0
+
+
+async def test_run_batch_skips_unlocked_profile(app_client):
+ await _seed_profile(locked=False) # not affirmed
+ await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
+ r = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
+ assert r["considered"] == 0
+
+
+async def test_due_logic_unit():
+ from app.routers.coaching import _is_due
+
+ now = datetime(2026, 6, 16, tzinfo=timezone.utc)
+ assert _is_due("weekly", None, now) is True
+ assert _is_due("weekly", now - timedelta(days=8), now) is True
+ assert _is_due("weekly", now - timedelta(days=3), now) is False
+ assert _is_due("off", None, now) is False
+
+
+async def test_coaching_requires_auth(app_client):
+ r = await app_client.get("/discovery/coaching/preferences")
+ assert r.status_code == 401
diff --git a/tests/test_coaching_prefs.py b/tests/test_coaching_prefs.py
new file mode 100644
index 0000000..cd34c2a
--- /dev/null
+++ b/tests/test_coaching_prefs.py
@@ -0,0 +1,36 @@
+"""Unit tests for the deterministic coaching-preference generator."""
+from app.services.coaching import ALLOWED, generate_preferences
+
+
+def test_gut_defaults_are_direct_and_high_friction():
+ p = generate_preferences({"triad": "gut"})
+ assert p["coaching_style"] == "direct"
+ assert p["friction_tolerance"] == "high"
+ assert p["prefer_questions_over_directives"] is False
+
+
+def test_head_defaults_are_reflective_and_questions():
+ p = generate_preferences({"triad": "head"})
+ assert p["coaching_style"] == "reflective"
+ assert p["coaching_frequency"] == "biweekly"
+ assert p["prefer_questions_over_directives"] is True
+
+
+def test_heart_defaults_are_warm_low_threshold():
+ p = generate_preferences({"triad": "heart"})
+ assert p["coaching_style"] == "warm"
+ assert p["misalignment_threshold"] == "low"
+
+
+def test_unknown_triad_uses_gentle_fallback():
+ p = generate_preferences({"triad": None})
+ assert p["coaching_style"] == "warm"
+ assert p["prefer_questions_over_directives"] is True
+
+
+def test_all_generated_values_are_within_allowed_sets():
+ for triad in ("gut", "heart", "head", None, "weird"):
+ p = generate_preferences({"triad": triad})
+ for field, allowed in ALLOWED.items():
+ assert p[field] in allowed, (triad, field, p[field])
+ assert isinstance(p["prefer_questions_over_directives"], bool)
diff --git a/tests/test_static_discovery.py b/tests/test_static_discovery.py
index 0c263a7..0e4aaf4 100644
--- a/tests/test_static_discovery.py
+++ b/tests/test_static_discovery.py
@@ -48,6 +48,20 @@ def test_profile_page_links_to_reflection():
assert "/static/reflect.html" in html
+def test_coaching_page_uses_coaching_endpoints():
+ """Phase 3 coaching page drives preferences + check-ins via authedFetch."""
+ html = Path("app/static/coaching.html").read_text(encoding="utf-8")
+ assert "/discovery/coaching/preferences" in html
+ assert "/discovery/coaching/checkins" in html
+ assert "authedFetch" in html
+ assert "user_id" not in html
+
+
+def test_profile_page_links_to_coaching():
+ html = Path("app/static/profile.html").read_text(encoding="utf-8")
+ assert "/static/coaching.html" in html
+
+
def test_auth_helper_sends_credentials_and_refreshes():
js = Path("app/static/auth.js").read_text(encoding="utf-8")