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:
Joel Salmon
2026-06-16 21:10:54 -05:00
parent b4d8d17aed
commit 50453901b3
13 changed files with 1317 additions and 1 deletions
+92
View File
@@ -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
+106
View File
@@ -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")
+7 -1
View File
@@ -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")
+58
View File
@@ -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
)
+322
View File
@@ -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
)
+42
View File
@@ -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
+170
View File
@@ -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
+238
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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>
+6
View File
@@ -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;
+30
View File
@@ -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;
}
+196
View File
@@ -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
+36
View File
@@ -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)
+14
View File
@@ -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")