mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 04:50:35 +00:00
Phase 3: coaching preferences + weekly check-in engine
Add coaching preferences (auto-derived from the profile, user-overridable) and a periodic check-in engine that quotes the person's own words and asks whether their direction still feels valid — mirror, not compass. - Preferences are deterministic: a documented triad mapping (gut → direct/ higher-friction, heart → warm/drift-sensitive, head → reflective/question-led) produces defaults for the six fields (coaching_frequency, coaching_style, misalignment_threshold, friction_tolerance, prefer_questions_over_directives, time_of_day_preference). PUT overrides; regenerate re-derives. - CheckinCoach (app/services/coaching.py): Anthropic-backed; writes a check-in that quotes the person's goals back and asks if the direction still holds. - Endpoints (app/routers/coaching.py): GET/PUT/regenerate preferences; GET/POST checkins; respond (records still_valid); admin POST /run is the weekly batch (due = cadence elapsed + locked profile), intended for a cron. - Models + migration 005: coaching_preferences (per user) and coaching_checkin. - Frontend: coaching.html (preferences form + check-in feed); linked from profile.html. Tests: 68 passing (added deterministic-preference unit tests and coaching endpoint/batch tests; run in-container). README updated for Phase 3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -11,7 +11,12 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
from app import models # noqa: F401 - register ORM models with Base.metadata
|
||||
from app.auth import ensure_api_key_admin
|
||||
from app.database import AsyncSessionLocal, Base, engine
|
||||
from app.routers import activity, auth as auth_router, discovery
|
||||
from app.routers import (
|
||||
activity,
|
||||
auth as auth_router,
|
||||
coaching,
|
||||
discovery,
|
||||
)
|
||||
from app.routers.activity import prune_old_activity
|
||||
from app.tracking import ActivityTrackingMiddleware
|
||||
|
||||
@@ -64,6 +69,7 @@ app.add_middleware(
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(activity.router)
|
||||
app.include_router(discovery.router)
|
||||
app.include_router(coaching.router)
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
|
||||
@@ -188,3 +188,61 @@ class ProfileRevision(Base):
|
||||
fields_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class CoachingPreferences(Base):
|
||||
"""Phase 3: how this person wants to be coached. Auto-generated from their
|
||||
Enneagram/Ikigai profile, then overridable. One row per user."""
|
||||
|
||||
__tablename__ = "coaching_preferences"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), unique=True, nullable=False, index=True
|
||||
)
|
||||
# The profile the defaults were derived from (provenance).
|
||||
profile_id: Mapped[Optional[str]] = mapped_column(
|
||||
String, ForeignKey("discovery_profile.id"), nullable=True
|
||||
)
|
||||
|
||||
coaching_frequency: Mapped[str] = mapped_column(String, nullable=False)
|
||||
coaching_style: Mapped[str] = mapped_column(String, nullable=False)
|
||||
misalignment_threshold: Mapped[str] = mapped_column(String, nullable=False)
|
||||
friction_tolerance: Mapped[str] = mapped_column(String, nullable=False)
|
||||
prefer_questions_over_directives: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
time_of_day_preference: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
# True while still using auto-derived defaults; False once the user edits.
|
||||
auto_generated: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class CoachingCheckin(Base):
|
||||
"""Phase 3: a periodic coaching check-in. The body quotes the person's own
|
||||
words and asks whether their stated direction still feels valid (mirror,
|
||||
not compass). The person's answer is recorded in still_valid."""
|
||||
|
||||
__tablename__ = "coaching_checkin"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
profile_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("discovery_profile.id"), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, index=True
|
||||
)
|
||||
# The person's self-assessment: is their direction still valid?
|
||||
still_valid: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
|
||||
response_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, nullable=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Phase 3 coaching routes: preferences (auto-generated + overridable) and the
|
||||
weekly check-in engine.
|
||||
|
||||
All routes are user-scoped via the dual-auth dependency; the batch ``/run``
|
||||
trigger is admin-only (a cron calls it weekly).
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.auth import get_current_user, require_admin
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
CoachingCheckin,
|
||||
CoachingPreferences,
|
||||
DiscoveryProfile,
|
||||
User,
|
||||
)
|
||||
from app.services.coaching import (
|
||||
ALLOWED,
|
||||
CheckinCoach,
|
||||
CheckinError,
|
||||
generate_preferences,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/discovery/coaching", tags=["coaching"])
|
||||
|
||||
# How long between check-ins for each cadence; "off" means never.
|
||||
_FREQUENCY_DAYS = {"weekly": 7, "biweekly": 14, "monthly": 30, "off": None}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def _latest_profile(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> DiscoveryProfile | None:
|
||||
stmt = (
|
||||
select(DiscoveryProfile)
|
||||
.where(DiscoveryProfile.user_id == user_id)
|
||||
.order_by(DiscoveryProfile.generated_at.desc())
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
async def _get_preferences(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> CoachingPreferences | None:
|
||||
stmt = select(CoachingPreferences).where(
|
||||
CoachingPreferences.user_id == user_id
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
def _profile_dict(profile: DiscoveryProfile) -> dict:
|
||||
return {
|
||||
"triad": profile.triad,
|
||||
"love_summary": profile.love_summary,
|
||||
"strength_summary": profile.strength_summary,
|
||||
"mission_summary": profile.mission_summary,
|
||||
"vocation_summary": profile.vocation_summary,
|
||||
"overlap_narrative": profile.overlap_narrative,
|
||||
"short_term_goals": profile.short_term_goals,
|
||||
"long_term_goals": profile.long_term_goals,
|
||||
}
|
||||
|
||||
|
||||
def _prefs_out(p: CoachingPreferences) -> schemas.CoachingPreferencesOut:
|
||||
return schemas.CoachingPreferencesOut(
|
||||
coaching_frequency=p.coaching_frequency,
|
||||
coaching_style=p.coaching_style,
|
||||
misalignment_threshold=p.misalignment_threshold,
|
||||
friction_tolerance=p.friction_tolerance,
|
||||
prefer_questions_over_directives=p.prefer_questions_over_directives,
|
||||
time_of_day_preference=p.time_of_day_preference,
|
||||
auto_generated=p.auto_generated,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _checkin_out(c: CoachingCheckin) -> schemas.CheckinOut:
|
||||
return schemas.CheckinOut(
|
||||
id=c.id,
|
||||
body=c.body,
|
||||
created_at=c.created_at,
|
||||
still_valid=c.still_valid,
|
||||
response_note=c.response_note,
|
||||
acknowledged_at=c.acknowledged_at,
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_preferences(
|
||||
db: AsyncSession, user: User
|
||||
) -> CoachingPreferences:
|
||||
"""Return the user's preferences, deriving defaults from their latest
|
||||
profile on first access. 404 if they have no profile yet."""
|
||||
prefs = await _get_preferences(db, user.id)
|
||||
if prefs is not None:
|
||||
return prefs
|
||||
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No profile yet; complete self-discovery first.",
|
||||
)
|
||||
defaults = generate_preferences(_profile_dict(profile))
|
||||
now = _now()
|
||||
prefs = CoachingPreferences(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user.id,
|
||||
profile_id=profile.id,
|
||||
auto_generated=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**defaults,
|
||||
)
|
||||
db.add(prefs)
|
||||
await db.commit()
|
||||
await db.refresh(prefs)
|
||||
return prefs
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=schemas.CoachingPreferencesOut)
|
||||
async def get_preferences(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return _prefs_out(await _ensure_preferences(db, user))
|
||||
|
||||
|
||||
@router.put("/preferences", response_model=schemas.CoachingPreferencesOut)
|
||||
async def update_preferences(
|
||||
payload: schemas.CoachingPreferencesUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
prefs = await _ensure_preferences(db, user)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
for field, value in updates.items():
|
||||
if field in ALLOWED and value not in ALLOWED[field]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid {field}: {value!r}. "
|
||||
f"Allowed: {sorted(ALLOWED[field])}",
|
||||
)
|
||||
setattr(prefs, field, value)
|
||||
prefs.auto_generated = False
|
||||
prefs.updated_at = _now()
|
||||
await db.commit()
|
||||
await db.refresh(prefs)
|
||||
return _prefs_out(prefs)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/preferences/regenerate",
|
||||
response_model=schemas.CoachingPreferencesOut,
|
||||
)
|
||||
async def regenerate_preferences(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Re-derive defaults from the latest profile, discarding overrides."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
prefs = await _ensure_preferences(db, user)
|
||||
for field, value in generate_preferences(_profile_dict(profile)).items():
|
||||
setattr(prefs, field, value)
|
||||
prefs.profile_id = profile.id
|
||||
prefs.auto_generated = True
|
||||
prefs.updated_at = _now()
|
||||
await db.commit()
|
||||
await db.refresh(prefs)
|
||||
return _prefs_out(prefs)
|
||||
|
||||
|
||||
@router.get("/checkins", response_model=list[schemas.CheckinOut])
|
||||
async def list_checkins(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
stmt = (
|
||||
select(CoachingCheckin)
|
||||
.where(CoachingCheckin.user_id == user.id)
|
||||
.order_by(CoachingCheckin.created_at.desc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [_checkin_out(c) for c in rows]
|
||||
|
||||
|
||||
async def _generate_checkin(
|
||||
db: AsyncSession, user_id: str, profile: DiscoveryProfile,
|
||||
prefs: CoachingPreferences,
|
||||
) -> CoachingCheckin:
|
||||
"""Generate and persist one check-in. Caller commits."""
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
coach = CheckinCoach(api_key=api_key, model=model)
|
||||
body = await coach.generate(
|
||||
_profile_dict(profile), _prefs_out(prefs).model_dump()
|
||||
)
|
||||
checkin = CoachingCheckin(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
profile_id=profile.id,
|
||||
body=body,
|
||||
created_at=_now(),
|
||||
)
|
||||
db.add(checkin)
|
||||
return checkin
|
||||
|
||||
|
||||
@router.post("/checkins", response_model=schemas.CheckinOut)
|
||||
async def create_checkin(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate a check-in now for the current user (on demand)."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
prefs = await _ensure_preferences(db, user)
|
||||
try:
|
||||
checkin = await _generate_checkin(db, user.id, profile, prefs)
|
||||
except CheckinError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
await db.commit()
|
||||
await db.refresh(checkin)
|
||||
return _checkin_out(checkin)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/checkins/{checkin_id}/respond", response_model=schemas.CheckinOut
|
||||
)
|
||||
async def respond_to_checkin(
|
||||
checkin_id: str,
|
||||
payload: schemas.CheckinRespondRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Record the person's self-assessment: is their direction still valid?"""
|
||||
checkin = await db.get(CoachingCheckin, checkin_id)
|
||||
if checkin is None or (
|
||||
checkin.user_id != user.id and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Check-in not found")
|
||||
checkin.still_valid = payload.still_valid
|
||||
checkin.response_note = payload.note or None
|
||||
checkin.acknowledged_at = _now()
|
||||
await db.commit()
|
||||
await db.refresh(checkin)
|
||||
return _checkin_out(checkin)
|
||||
|
||||
|
||||
async def _last_checkin_at(
|
||||
db: AsyncSession, user_id: str
|
||||
) -> datetime | None:
|
||||
stmt = (
|
||||
select(CoachingCheckin.created_at)
|
||||
.where(CoachingCheckin.user_id == user_id)
|
||||
.order_by(CoachingCheckin.created_at.desc())
|
||||
)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
def _is_due(frequency: str, last_at: datetime | None, now: datetime) -> bool:
|
||||
days = _FREQUENCY_DAYS.get(frequency)
|
||||
if days is None: # "off" or unknown cadence
|
||||
return False
|
||||
if last_at is None:
|
||||
return True
|
||||
if last_at.tzinfo is None:
|
||||
last_at = last_at.replace(tzinfo=timezone.utc)
|
||||
return now - last_at >= timedelta(days=days)
|
||||
|
||||
|
||||
@router.post("/run", response_model=schemas.RunCheckinsResponse)
|
||||
async def run_due_checkins(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
):
|
||||
"""The weekly batch job: generate a check-in for every eligible user whose
|
||||
cadence is due. Eligible = has coaching preferences (cadence != off) and an
|
||||
affirmed (locked) profile. Intended to be invoked by a weekly cron."""
|
||||
now = _now()
|
||||
prefs_rows = (
|
||||
await db.execute(select(CoachingPreferences))
|
||||
).scalars().all()
|
||||
|
||||
considered = 0
|
||||
generated = 0
|
||||
for prefs in prefs_rows:
|
||||
if _FREQUENCY_DAYS.get(prefs.coaching_frequency) is None:
|
||||
continue
|
||||
profile = await _latest_profile(db, prefs.user_id)
|
||||
if profile is None or not profile.locked:
|
||||
continue
|
||||
considered += 1
|
||||
last_at = await _last_checkin_at(db, prefs.user_id)
|
||||
if not _is_due(prefs.coaching_frequency, last_at, now):
|
||||
continue
|
||||
try:
|
||||
await _generate_checkin(db, prefs.user_id, profile, prefs)
|
||||
generated += 1
|
||||
except CheckinError:
|
||||
# Skip this user this run; a transient model error shouldn't abort
|
||||
# the whole batch.
|
||||
continue
|
||||
await db.commit()
|
||||
return schemas.RunCheckinsResponse(
|
||||
considered=considered, generated=generated
|
||||
)
|
||||
@@ -108,6 +108,48 @@ class ProfileRevisionOut(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# -- Phase 3: coaching preferences + check-ins -------------------------------
|
||||
|
||||
|
||||
class CoachingPreferencesOut(BaseModel):
|
||||
coaching_frequency: str
|
||||
coaching_style: str
|
||||
misalignment_threshold: str
|
||||
friction_tolerance: str
|
||||
prefer_questions_over_directives: bool
|
||||
time_of_day_preference: str
|
||||
auto_generated: bool
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CoachingPreferencesUpdate(BaseModel):
|
||||
coaching_frequency: Optional[str] = None
|
||||
coaching_style: Optional[str] = None
|
||||
misalignment_threshold: Optional[str] = None
|
||||
friction_tolerance: Optional[str] = None
|
||||
prefer_questions_over_directives: Optional[bool] = None
|
||||
time_of_day_preference: Optional[str] = None
|
||||
|
||||
|
||||
class CheckinOut(BaseModel):
|
||||
id: str
|
||||
body: str
|
||||
created_at: datetime
|
||||
still_valid: Optional[bool] = None
|
||||
response_note: Optional[str] = None
|
||||
acknowledged_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CheckinRespondRequest(BaseModel):
|
||||
still_valid: bool
|
||||
note: str = ""
|
||||
|
||||
|
||||
class RunCheckinsResponse(BaseModel):
|
||||
considered: int
|
||||
generated: int
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Phase 3 coaching: preference generation + the weekly check-in coach.
|
||||
|
||||
Two distinct pieces:
|
||||
|
||||
- ``generate_preferences`` is DETERMINISTIC. Coaching preferences are a
|
||||
structural read of the person's Enneagram centre, so they are derived from a
|
||||
documented mapping (no LLM, fully testable). The user can override any field.
|
||||
|
||||
- ``CheckinCoach`` is the LLM piece. It writes a periodic check-in that quotes
|
||||
the person's OWN words and asks whether their stated direction still feels
|
||||
valid. Mirror, not compass: it asks, it never judges or prescribes.
|
||||
"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||
MAX_TOKENS = 600
|
||||
|
||||
# Allowed values for each preference field (validated at the API boundary).
|
||||
ALLOWED = {
|
||||
"coaching_frequency": {"weekly", "biweekly", "monthly", "off"},
|
||||
"coaching_style": {"direct", "warm", "reflective"},
|
||||
"misalignment_threshold": {"low", "medium", "high"},
|
||||
"friction_tolerance": {"low", "medium", "high"},
|
||||
"time_of_day_preference": {"morning", "afternoon", "evening"},
|
||||
}
|
||||
|
||||
# Per-triad defaults. Rationale:
|
||||
# gut — acts from instinct; wants it direct, tolerates friction, fewer
|
||||
# questions, a weekly nudge in the morning.
|
||||
# heart— navigates by feeling/connection; wants warmth, is sensitive to
|
||||
# drift (low threshold), prefers questions.
|
||||
# head — thinks before committing; wants reflective space, low friction
|
||||
# tolerance, questions over directives, a slower (biweekly) cadence.
|
||||
_TRIAD_DEFAULTS = {
|
||||
"gut": {
|
||||
"coaching_frequency": "weekly",
|
||||
"coaching_style": "direct",
|
||||
"misalignment_threshold": "medium",
|
||||
"friction_tolerance": "high",
|
||||
"prefer_questions_over_directives": False,
|
||||
"time_of_day_preference": "morning",
|
||||
},
|
||||
"heart": {
|
||||
"coaching_frequency": "weekly",
|
||||
"coaching_style": "warm",
|
||||
"misalignment_threshold": "low",
|
||||
"friction_tolerance": "medium",
|
||||
"prefer_questions_over_directives": True,
|
||||
"time_of_day_preference": "morning",
|
||||
},
|
||||
"head": {
|
||||
"coaching_frequency": "biweekly",
|
||||
"coaching_style": "reflective",
|
||||
"misalignment_threshold": "medium",
|
||||
"friction_tolerance": "low",
|
||||
"prefer_questions_over_directives": True,
|
||||
"time_of_day_preference": "evening",
|
||||
},
|
||||
}
|
||||
|
||||
# Used when the triad is missing/unknown: a gentle, question-led default that
|
||||
# is consistent with mirror-not-compass.
|
||||
_FALLBACK_DEFAULTS = {
|
||||
"coaching_frequency": "weekly",
|
||||
"coaching_style": "warm",
|
||||
"misalignment_threshold": "medium",
|
||||
"friction_tolerance": "medium",
|
||||
"prefer_questions_over_directives": True,
|
||||
"time_of_day_preference": "morning",
|
||||
}
|
||||
|
||||
|
||||
def generate_preferences(profile: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Derive default coaching preferences from a profile's Enneagram centre.
|
||||
|
||||
Args:
|
||||
profile: at least ``{"triad": "gut"|"heart"|"head"|None}``.
|
||||
|
||||
Returns:
|
||||
A dict with all six preference fields.
|
||||
"""
|
||||
triad = (profile.get("triad") or "").lower()
|
||||
return dict(_TRIAD_DEFAULTS.get(triad, _FALLBACK_DEFAULTS))
|
||||
|
||||
|
||||
class CheckinError(Exception):
|
||||
"""Raised when check-in generation fails (API error or empty output)."""
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are an AI coach writing a brief, periodic check-in for a person using a self-discovery tool. You are a MIRROR, never a compass.
|
||||
|
||||
THE PERSON'S OWN WORDS (their profile):
|
||||
{profile}
|
||||
|
||||
HOW THEY WANT TO BE COACHED:
|
||||
{prefs}
|
||||
|
||||
YOUR TASK:
|
||||
- Write a short check-in (3-5 sentences) that QUOTES the person's own words back to them — a specific phrase from their goals or their sense of purpose, in quotation marks.
|
||||
- Then ask, gently and openly, whether that direction still feels true for them right now. Invite them to say if anything has shifted.
|
||||
|
||||
ABSOLUTE RULES (mirror, not compass):
|
||||
- NEVER tell them what to do, whether they are on or off track, or what they "should" pursue. You ASK; you do not judge.
|
||||
- NEVER invent goals or direction they did not state. Only reflect their own words.
|
||||
- Do not mention Enneagram type numbers.
|
||||
- Match their preferred style ({style}). If they prefer questions over directives ({prefer_questions}), lead with a question rather than a statement.
|
||||
- Plain language, second person (you/your). No preamble, no sign-off, no markdown — just the check-in text."""
|
||||
|
||||
|
||||
class CheckinCoach:
|
||||
"""Generates the text of a single coaching check-in."""
|
||||
|
||||
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
|
||||
if not api_key:
|
||||
raise CheckinError(
|
||||
"ANTHROPIC_API_KEY is not set; cannot generate a check-in."
|
||||
)
|
||||
self.model = model
|
||||
self.client = AsyncAnthropic(api_key=api_key)
|
||||
|
||||
@staticmethod
|
||||
def _profile_block(profile: Dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"- What you love: {profile.get('love_summary') or '(none)'}",
|
||||
f"- What the world needs from you: {profile.get('mission_summary') or '(none)'}",
|
||||
f"- Where it converges: {profile.get('overlap_narrative') or '(none)'}",
|
||||
f"- Near-term goals: {profile.get('short_term_goals') or '(none stated)'}",
|
||||
f"- Long-term goals: {profile.get('long_term_goals') or '(none stated)'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _prefs_block(prefs: Dict[str, Any]) -> str:
|
||||
return (
|
||||
f"- style: {prefs.get('coaching_style')}\n"
|
||||
f"- prefers questions over directives: {prefs.get('prefer_questions_over_directives')}\n"
|
||||
f"- friction tolerance: {prefs.get('friction_tolerance')}"
|
||||
)
|
||||
|
||||
async def generate(
|
||||
self, profile: Dict[str, Any], prefs: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Produce the check-in body text. Raises CheckinError on failure."""
|
||||
system = SYSTEM_PROMPT.format(
|
||||
profile=self._profile_block(profile),
|
||||
prefs=self._prefs_block(prefs),
|
||||
style=prefs.get("coaching_style", "warm"),
|
||||
prefer_questions=prefs.get("prefer_questions_over_directives", True),
|
||||
)
|
||||
try:
|
||||
response = await self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=MAX_TOKENS,
|
||||
system=system,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Write my check-in for this week.",
|
||||
}
|
||||
],
|
||||
)
|
||||
text = response.content[0].text.strip()
|
||||
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
|
||||
raise CheckinError(f"Anthropic API call failed: {exc}") from exc
|
||||
|
||||
if not text:
|
||||
raise CheckinError("Model returned an empty check-in.")
|
||||
return text
|
||||
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ImpactFlow — Coaching</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script src="/static/auth.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="brand">ImpactFlow · Coaching</div>
|
||||
<div id="prefs"><p>Loading your preferences…</p></div>
|
||||
<p class="section-label" style="margin-top:36px">Check-ins</p>
|
||||
<div id="checkins"><p>Loading…</p></div>
|
||||
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const SELECTS = {
|
||||
coaching_frequency: ["weekly", "biweekly", "monthly", "off"],
|
||||
coaching_style: ["direct", "warm", "reflective"],
|
||||
misalignment_threshold: ["low", "medium", "high"],
|
||||
friction_tolerance: ["low", "medium", "high"],
|
||||
time_of_day_preference: ["morning", "afternoon", "evening"],
|
||||
};
|
||||
const LABELS = {
|
||||
coaching_frequency: "How often should I check in?",
|
||||
coaching_style: "Coaching style",
|
||||
misalignment_threshold: "Flag drift when it's…",
|
||||
friction_tolerance: "Friction tolerance",
|
||||
time_of_day_preference: "Best time of day",
|
||||
};
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function selectField(key, value) {
|
||||
const opts = SELECTS[key]
|
||||
.map(
|
||||
(o) =>
|
||||
`<option value="${o}"${o === value ? " selected" : ""}>${o}</option>`
|
||||
)
|
||||
.join("");
|
||||
return `<div class="edit-field">
|
||||
<label for="pref_${key}">${LABELS[key]}</label>
|
||||
<select id="pref_${key}" class="pref-select">${opts}</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrefs(p) {
|
||||
const fields = Object.keys(SELECTS).map((k) => selectField(k, p[k])).join("");
|
||||
const origin =
|
||||
p.auto_generated
|
||||
? "Auto-generated from your profile."
|
||||
: "Customized by you.";
|
||||
document.getElementById("prefs").innerHTML = `
|
||||
<p class="section-label">How you want to be coached</p>
|
||||
<p class="edit-help">${origin}</p>
|
||||
<div class="pref-grid">${fields}</div>
|
||||
<div class="edit-field" style="display:flex;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="pref_prefer_questions_over_directives" ${
|
||||
p.prefer_questions_over_directives ? "checked" : ""
|
||||
} />
|
||||
<label for="pref_prefer_questions_over_directives" style="margin:0">
|
||||
Prefer questions over directives
|
||||
</label>
|
||||
</div>
|
||||
<div class="nav">
|
||||
<button class="btn-ghost" id="regenBtn">Reset to suggested</button>
|
||||
<button class="btn-primary" id="saveBtn">Save preferences</button>
|
||||
</div>
|
||||
<div id="prefMsg"></div>`;
|
||||
document.getElementById("saveBtn").addEventListener("click", savePrefs);
|
||||
document.getElementById("regenBtn").addEventListener("click", regenPrefs);
|
||||
}
|
||||
|
||||
function collectPrefs() {
|
||||
const out = {};
|
||||
for (const k of Object.keys(SELECTS)) {
|
||||
out[k] = document.getElementById(`pref_${k}`).value;
|
||||
}
|
||||
out.prefer_questions_over_directives = document.getElementById(
|
||||
"pref_prefer_questions_over_directives"
|
||||
).checked;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function savePrefs() {
|
||||
const btn = document.getElementById("saveBtn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/preferences", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(collectPrefs()),
|
||||
});
|
||||
if (!res.ok) throw new Error("Could not save");
|
||||
renderPrefs(await res.json());
|
||||
note("prefMsg", "Saved.");
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
note("prefMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenPrefs() {
|
||||
try {
|
||||
const res = await authedFetch(
|
||||
"/discovery/coaching/preferences/regenerate",
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) throw new Error("Could not reset");
|
||||
renderPrefs(await res.json());
|
||||
note("prefMsg", "Reset to the profile's suggested defaults.");
|
||||
} catch (e) {
|
||||
note("prefMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function note(where, text, isError) {
|
||||
const el = document.getElementById(where);
|
||||
if (el)
|
||||
el.innerHTML = `<p class="${isError ? "error-box" : "note"}">${escapeHtml(
|
||||
text
|
||||
)}</p>`;
|
||||
}
|
||||
|
||||
function renderCheckins(list) {
|
||||
const items = list.length
|
||||
? list
|
||||
.map((c) => {
|
||||
const answered = c.acknowledged_at;
|
||||
const status = answered
|
||||
? `<p class="note">${
|
||||
c.still_valid
|
||||
? "You said this still feels true."
|
||||
: "You said this has shifted."
|
||||
}${c.response_note ? " — " + escapeHtml(c.response_note) : ""}</p>`
|
||||
: `<div class="nav" data-id="${c.id}">
|
||||
<button class="btn-ghost resp" data-v="false">It's shifted</button>
|
||||
<button class="btn-primary resp" data-v="true">Still feels true</button>
|
||||
</div>`;
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<p>${escapeHtml(c.body)}</p>
|
||||
${status}
|
||||
</div>`;
|
||||
})
|
||||
.join("")
|
||||
: `<p class="edit-help">No check-ins yet.</p>`;
|
||||
document.getElementById("checkins").innerHTML = `
|
||||
<div class="nav" style="justify-content:flex-end">
|
||||
<button class="btn-primary" id="genBtn">Check in with me now</button>
|
||||
</div>
|
||||
<div id="genMsg"></div>
|
||||
${items}`;
|
||||
document.getElementById("genBtn").addEventListener("click", generateNow);
|
||||
document.querySelectorAll(".resp").forEach((b) =>
|
||||
b.addEventListener("click", () => respond(b))
|
||||
);
|
||||
}
|
||||
|
||||
async function generateNow() {
|
||||
const btn = document.getElementById("genBtn");
|
||||
btn.disabled = true;
|
||||
note("genMsg", "Thinking…");
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/checkins", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({ detail: "Failed" }));
|
||||
throw new Error(d.detail || "Failed");
|
||||
}
|
||||
await loadCheckins();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
note("genMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function respond(btn) {
|
||||
const id = btn.closest(".nav").getAttribute("data-id");
|
||||
const stillValid = btn.getAttribute("data-v") === "true";
|
||||
try {
|
||||
const res = await authedFetch(
|
||||
`/discovery/coaching/checkins/${encodeURIComponent(id)}/respond`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ still_valid: stillValid }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error("Could not record");
|
||||
await loadCheckins();
|
||||
} catch (e) {
|
||||
note("genMsg", e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrefs() {
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/preferences");
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({ detail: "Failed" }));
|
||||
throw new Error(d.detail || "Failed");
|
||||
}
|
||||
renderPrefs(await res.json());
|
||||
} catch (e) {
|
||||
document.getElementById("prefs").innerHTML =
|
||||
`<div class="error-box">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCheckins() {
|
||||
try {
|
||||
const res = await authedFetch("/discovery/coaching/checkins");
|
||||
if (!res.ok) throw new Error("Could not load check-ins");
|
||||
renderCheckins(await res.json());
|
||||
} catch (e) {
|
||||
document.getElementById("checkins").innerHTML =
|
||||
`<div class="error-box">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
loadPrefs();
|
||||
loadCheckins();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -142,6 +142,11 @@
|
||||
<a href="/static/reflect.html">Not quite right? Talk it through with your coach →</a>
|
||||
</p>`;
|
||||
|
||||
const coachingLink =
|
||||
`<p class="back-link" style="text-align:center;margin-top:10px">
|
||||
<a href="/static/coaching.html">Coaching preferences & check-ins →</a>
|
||||
</p>`;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="profile-narrative">${escapeHtml(
|
||||
profile.overlap_narrative
|
||||
@@ -157,6 +162,7 @@
|
||||
|
||||
${goalsBlock}
|
||||
${actions}
|
||||
${coachingLink}
|
||||
`;
|
||||
|
||||
if (locked) return;
|
||||
|
||||
@@ -368,6 +368,36 @@ textarea.edit {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.pref-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pref-select {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid rgba(13, 27, 42, 0.18);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--navy);
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.pref-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.pref-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user