scripts/run-weekly-checkins.sh reads the admin API key from .env and POSTs the Phase 3 batch endpoint (/discovery/coaching/run); a weekly host crontab entry (Mon 08:00) invokes it. Running weekly services all cadences since the endpoint skips users who aren't due. README documents the install. Output logs to data/coaching-cron.log (gitignored). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
52 KiB
ImpactFlow Self-Discovery Module
ImpactFlow Self-Discovery is a standalone FastAPI service that turns seven
short reflection responses into a structured Enneagram + Ikigai + goals
profile. It is
designed to run independently from the main ImpactFlow app on port 8011 and
can be integrated into the larger product later.
The service presents a lightweight browser flow, stores the user's narrative answers in SQLite, sends those answers to Anthropic for structured extraction, persists the resulting profile, and displays a plain-language profile page.
Quick Explanation For AI Assistants
If you need to explain this app in detail, use this mental model:
- A user opens
/static/discovery.html. - The browser sends them through Google sign-in at
GET /api/auth/login;GET /api/auth/callbackmints an access + refresh token, sets them as httpOnly cookies, and redirects back into the app. The browser holds no tokens itself — the cookies are sent automatically on later requests. - The browser starts a discovery conversation with
POST /discovery/start— the backend derives theuser_idfrom the session cookie, not the body. - The user answers seven open-ended prompts: five discovery prompts plus a near-term (6–12 month) and a long-term (3–5 year) goal prompt.
- The browser saves all answers with
PUT /discovery/{conversation_id}/respond. - The browser asks the backend to analyze the saved answers with
POST /discovery/{conversation_id}/complete. - The backend calls Anthropic through
DiscoveryExtractor. - The extractor asks for JSON containing Enneagram, instinctual variant, Ikigai summaries, articulated short- and long-term goals, confidence flags, and optional extraction notes.
- The backend stores that JSON as a
DiscoveryProfilerow owned by the authenticated user. - The browser redirects to
/static/profile.html. - The profile page loads the newest profile with
GET /discovery/profile/me. While it is unlocked the user can revise their words withPATCH /discovery/profile/me, then lock it withPUT /discovery/profile/me/confirm. - Optionally (Phase 2) the user opens
/static/reflect.htmland refines the 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. - After affirming (Phase 3) the user opens
/static/coaching.htmlto 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 callsPOST /discovery/coaching/runto generate due check-ins. - (Phase 4) The ImpactFlow core time-tracker maps each logged task to a
profile foundation via
POST /discovery/integration/task-mappings. The rolled-up work patterns drive/static/dashboard.htmland are fed into the coaching check-ins so they can reflect where time has actually gone. - (Phase 5)
/static/visuals.htmlshows the Ikigai/Enneagram visuals and the goal-evolution timeline; the tracker can callsuggest-foundationfor smart tagging, and the reflect loop accepts afocusfor deeper goal-refinement.
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
resolves to a synthetic admin user (api-key-admin) so foreign keys stay
valid and admin-only routes work without a real Google sign-in.
The app is intentionally small: static HTML/CSS for the UI, FastAPI for the API, async SQLAlchemy for persistence, Alembic for migrations, SQLite for local storage, Google OAuth + httpOnly cookie sessions for browser auth, and Anthropic for the analysis step.
What The App Does
The module collects seven narrative prompts — five discovery prompts plus two goal prompts:
| Stored field | User-facing prompt | Purpose in extraction |
|---|---|---|
prompt_alive |
The Alive Moment | Reveals energy, motivation, strengths, and core need |
prompt_friction |
The Friction Moment | Strongest signal for Enneagram triad |
prompt_pull |
The Natural Pull | Helps infer instinctual variant and recurring interests |
prompt_recognition |
The Recognition Moment | Reveals what the person values being seen for |
prompt_future |
The Future Pull | Helps infer mission, vocation, and ideal future direction |
prompt_goals_short |
The Near Horizon | The person's own near-term (6–12 month) goals, articulated back |
prompt_goals_long |
The Long Horizon | The person's own long-term (3–5 year) goals, articulated back |
The generated profile includes:
| Field | Meaning |
|---|---|
triad |
One of gut, heart, or head |
probable_type |
Likely Enneagram type number, 1 through 9 |
wing |
Adjacent Enneagram wing type |
instinctual_variant |
One of sp, so, or sx |
instinctual_stack |
Ordered stack such as sp/so/sx |
love_summary |
Ikigai: what the user loves |
strength_summary |
Ikigai: what the user is good at |
mission_summary |
Ikigai: what the world needs from the user |
vocation_summary |
Ikigai: what the user can be paid for |
overlap_narrative |
Plain-language convergence narrative |
short_term_goals |
The user's own near-term (6–12 month) goals, articulated back |
long_term_goals |
The user's own long-term (3–5 year) goals, articulated back |
confidence |
Confidence flags for triad, type, variant, and Ikigai |
extraction_notes |
Optional ambiguity or caveat from the model |
locked |
Whether the user has confirmed the profile |
The profile page deliberately avoids showing the raw Enneagram type number to the user. It translates the triad into plain-language pattern descriptions and shows the Ikigai summaries as cards.
Architecture
Browser static UI MCP server / other machines
discovery.html X-API-Key: $IMPACTFLOW_API_KEY
profile.html
| |
| Google OAuth + |
| session cookie |
v v
+--------------------------------------------+
| FastAPI app |
| app/main.py (middleware stack) |
| app/auth.py (dual-auth dependency)|
| app/routers/auth.py /api/auth, /api/me
| app/routers/activity.py /api/activity
| app/routers/discovery.py /discovery/* |
| app/tracking.py (activity middleware) |
+--------------------------------------------+
|
v
Async SQLAlchemy + SQLite
app/database.py
app/models.py (users, refresh_tokens, activity_log,
discovery_conversation, discovery_profile)
data/discovery.db
|
v
Anthropic extraction
app/services/extractor.py
Important files:
| File | Role |
|---|---|
app/main.py |
FastAPI entrypoint, middleware wiring, lifespan hooks, health check, static file mount, root redirect |
app/auth.py |
Google OAuth registration, JWT issue/decode, dual-auth dependency, refresh-token hashing, domain allow-list |
app/tracking.py |
ActivityTrackingMiddleware and log_activity helper |
app/routers/auth.py |
OAuth endpoints, /api/me, sessions, refresh, logout, /api/me/stats |
app/routers/activity.py |
Activity feed, per-user summary, admin activity view, 90-day retention pruner |
app/routers/discovery.py |
Discovery API routes; all routes user-scoped via the auth dependency |
app/schemas.py |
Pydantic request and response models |
app/models.py |
SQLAlchemy ORM models for users, refresh tokens, activity log, conversations, and profiles |
app/database.py |
Async database engine, session factory, SQLite directory setup |
app/services/extractor.py |
Anthropic client wrapper, prompt, JSON parsing, retry logic |
app/migration_bootstrap.py |
Stamps pre-Alembic SQLite DBs as revision 001 so alembic upgrade head succeeds on older local databases |
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/dashboard.html |
Phase 4 goal dashboard: time per foundation + neglected ones |
app/static/visuals.html |
Phase 5 visualizations: Ikigai Venn, Enneagram diagram, goal-evolution timeline |
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/services/foundations.py |
The six foundations + pure work-pattern aggregator (Phase 4) |
app/services/profile_history.py |
Pure goal-evolution aggregator over profile revisions (Phase 5) |
app/services/tagging.py |
FoundationTagger: Anthropic smart-tagging suggestion (Phase 5) |
app/routers/coaching.py |
Phase 3 coaching routes: preferences, check-ins, weekly batch /run |
app/routers/integration.py |
Phase 4/5 integration: foundations, task-mappings, work-patterns, suggest-foundation |
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) |
alembic/versions/006_add_task_mapping.py |
Adds the task_mapping table (Phase 4) |
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, work-pattern wiring) |
tests/test_foundations.py |
Unit tests for the pure work-pattern aggregator |
tests/test_integration.py |
Tests for the task-to-goal integration endpoints |
tests/test_profile_history.py |
Unit tests for the pure goal-evolution aggregator |
tests/test_phase5.py |
Tests for goal-history and smart-tagging endpoints |
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) |
tests/test_migration_bootstrap.py |
Unit tests for the pre-Alembic SQLite stamping helper |
tests/test_static_discovery.py |
Guard tests for the static pages' cookie-session and edit contract |
tests/fixtures/{gut,head,heart}_type_responses.json |
Synthetic seven-prompt responses used by extractor tests |
smoke_test.py |
In-process end-to-end API smoke test with fake extraction; also exercises the auth and activity tracking surface |
Authentication
The get_current_user dependency in app/auth.py resolves a request from
three credential sources, tried in order: X-API-Key, then an
Authorization: Bearer access token, then the session cookie.
| Caller | Mechanism | Notes |
|---|---|---|
| Browser users | Google OAuth → httpOnly session cookies | Set by /api/auth/callback; the access cookie is refreshed silently via /api/auth/refresh |
| Scripts / API clients | Authorization: Bearer <access token> |
The same access token presented manually instead of via cookie |
| Machine-to-machine (MCP server) | X-API-Key: $IMPACTFLOW_API_KEY |
Resolves to a synthetic admin user api-key-admin so FK constraints stay valid |
The first real Google user to sign in is auto-promoted to role=admin;
every subsequent user defaults to role=user. Admin-only routes (e.g.
/api/admin/activity) check the role on the resolved user.
One-Time Google Cloud Setup
-
In https://console.cloud.google.com open APIs & Services → Credentials.
-
Create an OAuth 2.0 Client ID of type Web application.
-
Add authorized redirect URIs that match
OAUTH_REDIRECT_URIin.envexactly (scheme, host, port, path — Google treatslocalhostand127.0.0.1as distinct):http://localhost:8011/api/auth/callbackfor local http dev (setCOOKIE_SECURE=false)https://impactflow.teamci.org:8011/api/auth/callbackfor the deployed instance (HTTPS; setCOOKIE_SECURE=true)
-
Copy the client id and client secret into
.envasGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET. -
Generate two random 64-character strings and put them in
.envasJWT_SECRETandIMPACTFLOW_API_KEY:.venv/Scripts/python.exe -c "import secrets; print(secrets.token_urlsafe(48))"
OAuth Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/auth/login |
302s the browser to Google's consent screen |
GET |
/api/auth/callback |
Google redirects here with code; we exchange it for a Google access token, find-or-create the user, then return {access_token, refresh_token, user} |
POST |
/api/auth/refresh |
Body {refresh_token} → new short-lived access token |
POST |
/api/auth/logout |
Body {refresh_token} → revokes that refresh token (idempotent) |
access_token lifetime defaults to 15 minutes; refresh_token lifetime
defaults to 7 days. Both are configurable through .env. Refresh tokens are
stored as SHA-256 hashes — the raw value only exists in the response from
/api/auth/callback and /api/auth/refresh.
Profile And Session Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/me, /api/auth/me |
Current user profile |
PATCH |
/api/me |
Update display_name (email is owned by Google) |
GET |
/api/me/stats |
Conversations / profiles / locked-profiles / 30-day activity counts |
GET |
/api/me/sessions |
List active refresh tokens (id, device, created_at, expires_at) |
DELETE |
/api/me/sessions/{id} |
Revoke a refresh token |
Activity Tracking
ActivityTrackingMiddleware records one activity_log row per authenticated,
successful (status < 400), non-noisy request. The source column is set to
mcp when the request carries an X-API-Key header and web otherwise, so
Claude-initiated calls are distinguishable from browser activity. Rows older
than 90 days are pruned on app startup.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/activity?page=…&limit=… |
Paginated activity feed for the current user |
GET |
/api/activity/summary?days=30 |
Per-day counts, top resources, web/mcp ratio |
GET |
/api/admin/activity?user_id=… |
Admin-only cross-user feed |
Runtime Flow
1. App Startup
app/main.py creates the FastAPI app, wires the middleware stack
(ActivityTrackingMiddleware innermost, then SessionMiddleware for the
OAuth state cookie, CORSMiddleware outermost), includes the auth, activity,
and discovery routers, mounts /static, and redirects / to
/static/discovery.html.
The lifespan hook:
- Calls
Base.metadata.create_all()as a local development safety net. Alembic remains the source of truth for schema changes. - Calls
ensure_api_key_admin()so the synthetic admin user backingX-API-Keyexists before the first request arrives. - Calls
prune_old_activity()to drop activity log rows older than 90 days.
In Docker, the container's CMD runs three steps in order before serving:
python -m app.migration_bootstrap— ifdata/discovery.dbalready exists with the app tables but noalembic_versionrow (older local DBs created viacreate_allbefore Alembic existed), stamp it as revision001so step 2 does not try to recreate existing tables.alembic upgrade head— apply any outstanding migrations.uvicorn app.main:app --host 0.0.0.0 --port 8011— serve the app.
The Dockerfile also runs alembic upgrade head at build time against a
throwaway in-image DB as a sanity check that migrations apply cleanly. At
runtime the ./data volume shadows /app/data, so the runtime migration
step above is what populates the persistent database.
2. Starting A Conversation
POST /discovery/start requires authentication (session cookie, Bearer token,
or X-API-Key). It takes no body — the user_id is derived from the
authenticated user.
It creates a DiscoveryConversation row with:
- a UUID conversation id
user_idset to the authenticated user's idstarted_atin UTC- empty prompt fields
It returns:
{
"conversation_id": "uuid"
}
The static UI starts this conversation once the browser has a session cookie from the OAuth callback, and retries on submit if the first start call failed.
3. Saving Answers
PUT /discovery/{conversation_id}/respond accepts all seven prompt responses
(five discovery prompts plus the two goal prompts):
{
"prompt_alive": "I felt most alive when...",
"prompt_friction": "Something felt wrong when...",
"prompt_pull": "I naturally keep returning to...",
"prompt_recognition": "I felt seen when...",
"prompt_future": "If I could not fail...",
"prompt_goals_short": "In the next 6–12 months I want to...",
"prompt_goals_long": "In the next 3–5 years I want to..."
}
All fields default to empty, so a partial save is accepted.
It stores the responses on the existing conversation and returns:
{
"conversation_id": "uuid",
"status": "responses_saved"
}
If the conversation id does not exist, it returns 404.
4. Completing Analysis
POST /discovery/{conversation_id}/complete loads the conversation, builds a
compact response dictionary with keys alive, friction, pull,
recognition, future, goals_short, and goals_long, then calls
DiscoveryExtractor.extract().
The route rejects completion with:
404if the conversation does not exist400if all seven responses are blank502if Anthropic extraction fails or returns unusable output after retry
On success, it stores a new DiscoveryProfile, marks the conversation
completed_at, and returns the profile.
5. Loading The Profile
GET /discovery/profile/me fetches the newest profile for the authenticated
user by descending generated_at.
The profile page uses this route after redirect. This means one user can have multiple completed conversations, but the UI always displays the latest one.
6. Editing The Profile
PATCH /discovery/profile/me edits the prose of the newest profile. The body
is a partial update — only the fields supplied are changed — over the seven
editable text fields: love_summary, strength_summary, mission_summary,
vocation_summary, overlap_narrative, short_term_goals, and
long_term_goals. The AI's structural read (triad, probable_type, wing,
instinctual_variant, instinctual_stack) and confidence are not editable
here.
It rejects with:
404if the user has no profile409if the profile is locked (affirming makes it final)400if the body contains no editable fields
7. Confirming The Profile
PUT /discovery/profile/me/confirm locks the newest profile by setting
locked = true. This is the "This is me" affirmation on the profile page;
once locked, the profile can no longer be edited (PATCH returns 409). It
does not prevent future conversations from generating newer profiles.
8. Reflecting With The Coach (Phase 2)
The AI-coach reflection loop lets the person refine their profile through a
conversation before affirming it. The coach is a mirror, not a compass: it
reflects the profile back, asks whether it fits, and — only when the person
explicitly corrects or adds something — proposes revised text in the person's
own direction. It never prescribes goals or invents direction. Affirmation is
still the /confirm lock above; the loop is what happens before it.
POST /discovery/profile/me/reflectadvances the loop by one turn. An emptymessagestarts it (the coach's opening reflection); a non-emptymessageis recorded as the person's turn before the coach replies. When the coach proposes revisions, they are applied to the editable prose fields (never the structural Enneagram read) and snapshotted. Returns the coach turn, the (possibly revised) profile, andrevised/revision_note.409once locked.GET /discovery/profile/me/reflectionreturns the dialogue (orderedcoach/personturns) plus the current profile.GET /discovery/profile/me/revisionsreturns the profile's edit/iteration history, newest first. Every change is snapshotted inprofile_revisionwith asourceofextraction(initial),reflection, ormanual_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/preferencesreturns the preferences, deriving defaults from the latest profile on first access (404if no profile yet).PUT /discovery/coaching/preferencesoverrides any field (validated against the allowed value sets) and marks them user-customized.POST /discovery/coaching/preferences/regeneratere-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/checkinsgenerates a check-in now (on demand).GET /discovery/coaching/checkinslists them, newest first.PUT /discovery/coaching/checkins/{id}/respondrecords the self-assessment (still_valid+ optional note).POST /discovery/coaching/runis 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 notoffand an affirmed (locked) profile; due = no prior check-in or the cadence interval has elapsed.
10. Task-to-Goal Integration (Phase 4)
This is the boundary the ImpactFlow core time-tracker plugs into. A task
maps to a foundation — one of the six stable profile elements: love,
strength, mission, vocation, short_term, long_term.
Core-tracker contract: when a user logs time, the tracker (1) fetches the
options from GET /discovery/integration/foundations, (2) asks the person
"which goal does this build toward?", and (3) posts the answer to
POST /discovery/integration/task-mappings with {external_task_id, foundation, minutes, task_label?, occurred_at?}. It calls these endpoints as
the user (forwarded session/JWT) or service-to-service with X-API-Key.
GET /discovery/integration/foundations— the six foundations with the person's own text (what the tracker shows).404if no profile.POST /discovery/integration/task-mappings— record one logged unit of work.GET /discovery/integration/task-mappings?days=N— the user's mappings.GET /discovery/integration/work-patterns?days=N— per-foundation rollup (minutes, share, task count, last activity) plusneglectedfoundations. Powers the goal dashboard and feeds the coaching reminder engine: a check-in is given a plain-language summary of the last 14 days so it can reflect where time has and hasn't gone — as an observation to check against the person's own words, never a verdict (mirror, not compass).
11. Iteration & Polish (Phase 5)
- Goal-evolution history:
GET /discovery/profile/me/goal-historyderives a per-goal timeline (near- and long-term) from theprofile_revisionsnapshots captured since Phase 2 — one entry per actual change, withsourceandat. No new storage; it reads existing revisions. - Smart tagging:
POST /discovery/integration/suggest-foundation({task_label}) returns a suggestedfoundation+rationale+confidence. It only suggests — the person confirms by posting the task mapping. The core tracker uses this to pre-fill "which goal does this build toward?". - Deeper goal-refinement: the reflect loop (Phase 2) accepts an optional
focus(e.g."goals") that steers the coach toward sharpening the near/long-term goals — still a mirror. - Visualizations:
visuals.htmlrenders an Ikigai four-circle Venn and an Enneagram diagram from the profile (plain-language callouts — it does not headline the raw type number), plus the goal-evolution timeline.
API Reference
All /discovery/*, /api/me*, /api/activity*, and /api/admin/* routes
require authentication. The browser is authenticated by the session cookie set
at the OAuth callback; machine callers send X-API-Key. (A raw
Authorization: Bearer access token is also still accepted, e.g. for scripted
clients.) /api/auth/login, /api/auth/callback, /health, /, and
/static/* are public.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/ |
public | Redirects to /static/discovery.html |
GET |
/health |
public | Liveness check, returns {"status": "ok"} |
GET |
/api/auth/login |
public | 302 to Google consent screen |
GET |
/api/auth/callback |
public | OAuth callback; sets session cookies and redirects into the app |
POST |
/api/auth/refresh |
public (refresh cookie or body) | Mint a new access token; refreshes the access cookie |
POST |
/api/auth/logout |
public (refresh cookie or body) | Revoke the refresh token and clear session cookies |
GET |
/api/me, /api/auth/me |
yes | Current user profile |
PATCH |
/api/me |
yes | Update display_name |
GET |
/api/me/stats |
yes | Per-user usage stats |
GET |
/api/me/sessions |
yes | List active refresh tokens |
DELETE |
/api/me/sessions/{id} |
yes | Revoke a refresh token |
GET |
/api/activity |
yes | Paginated activity feed for current user |
GET |
/api/activity/summary |
yes | Aggregate activity stats |
GET |
/api/admin/activity |
admin | All-users activity feed |
POST |
/discovery/start |
yes | Begin a conversation (user derived from auth) |
PUT |
/discovery/{conversation_id}/respond |
yes | Save all seven responses |
POST |
/discovery/{conversation_id}/complete |
yes | Run extraction, store profile, return profile |
GET |
/discovery/profile/me |
yes | Fetch newest profile for the authenticated user |
PATCH |
/discovery/profile/me |
yes | Edit the newest profile's prose (409 if locked) |
PUT |
/discovery/profile/me/confirm |
yes | Lock newest profile for the authenticated user |
POST |
/discovery/profile/me/reflect |
yes | Advance the AI-coach reflection loop (Phase 2); applies revisions (409 if locked) |
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 |
GET |
/discovery/integration/foundations |
yes | The six mappable foundations with the person's own text |
POST |
/discovery/integration/task-mappings |
yes | Record a logged unit of work mapped to a foundation |
GET |
/discovery/integration/task-mappings |
yes | List the user's task mappings in a window |
GET |
/discovery/integration/work-patterns |
yes | Per-foundation work-pattern rollup over a window |
POST |
/discovery/integration/suggest-foundation |
yes | Smart tagging: suggest a foundation for a task (person confirms) |
GET |
/discovery/profile/me/goal-history |
yes | Timeline of how the person's goals evolved |
Data Model
users
One row per signed-in human, plus a single synthetic api-key-admin row
backing the X-API-Key dual-auth path.
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key; api-key-admin for the synthetic row |
email |
string | Unique, from Google |
display_name |
string | From Google name claim; editable via PATCH /api/me |
avatar_url |
string nullable | Google profile picture |
google_id |
string nullable, unique | Google sub claim; null only for the API-key row |
role |
string | user or admin; first real user is auto-promoted |
created_at |
datetime | UTC |
last_login_at |
datetime nullable | Updated on every OAuth callback |
refresh_tokens
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key |
user_id |
string | FK to users.id, indexed |
token_hash |
string | SHA-256 of the raw token; unique |
device |
string nullable | Captured from User-Agent at issue time |
created_at |
datetime | UTC |
expires_at |
datetime | UTC |
revoked_at |
datetime nullable | Set by logout or DELETE /api/me/sessions/{id} |
activity_log
Append-only audit trail. Pruned to 90 days on app startup.
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key |
user_id |
string | FK to users.id, indexed |
action |
string | view, create, update, delete, or other |
resource |
string | Coarse resource name parsed from the URL path |
resource_id |
string nullable | When a specific record id is identifiable |
metadata_json |
text nullable | JSON-encoded extra context |
source |
string | web or mcp (set from presence of X-API-Key) |
ip_address |
string nullable | From request.client.host |
user_agent |
text nullable | Truncated to 1000 chars |
created_at |
datetime | UTC, indexed |
discovery_conversation
Stores one seven-prompt response set.
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key |
user_id |
string | FK to users.id, indexed |
started_at |
datetime | UTC timestamp |
completed_at |
datetime nullable | Set after successful profile generation |
prompt_alive |
text nullable | First narrative response |
prompt_friction |
text nullable | Second narrative response |
prompt_pull |
text nullable | Third narrative response |
prompt_recognition |
text nullable | Fourth narrative response |
prompt_future |
text nullable | Fifth narrative response |
prompt_goals_short |
text nullable | Near-term (6–12 month) goal response |
prompt_goals_long |
text nullable | Long-term (3–5 year) goal response |
discovery_profile
Stores one extracted profile for one conversation.
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key |
user_id |
string | FK to users.id, indexed |
conversation_id |
string | Foreign key to discovery_conversation.id |
generated_at |
datetime | UTC timestamp |
triad |
string nullable | gut, heart, or head |
probable_type |
integer nullable | Enneagram type number |
wing |
integer nullable | Adjacent Enneagram wing |
instinctual_variant |
string nullable | sp, so, or sx |
instinctual_stack |
string nullable | Ordered stack |
love_summary |
text nullable | Ikigai love summary |
strength_summary |
text nullable | Ikigai strength summary |
mission_summary |
text nullable | Ikigai mission summary |
vocation_summary |
text nullable | Ikigai vocation summary |
overlap_narrative |
text nullable | Convergence narrative |
short_term_goals |
text nullable | Articulated near-term (6–12 month) goals |
long_term_goals |
text nullable | Articulated long-term (3–5 year) goals |
confidence_json |
text nullable | JSON string for confidence flags |
locked |
boolean | Defaults to false |
reflection_message
One turn in the Phase 2 AI-coach reflection loop (migration 004).
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key |
profile_id |
string | FK to discovery_profile.id, indexed |
user_id |
string | FK to users.id, indexed |
role |
string | coach (AI mirror) or person (the human) |
content |
text | The turn's text |
sequence |
integer | Monotonic order within a profile's thread |
created_at |
datetime | UTC |
profile_revision
A snapshot of a profile's editable prose at a point in time, so edits and
iterations are captured rather than overwritten (migration 004).
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key |
profile_id |
string | FK to discovery_profile.id, indexed |
user_id |
string | FK to users.id, indexed |
source |
string | extraction (initial), reflection, or manual_edit |
fields_json |
text | JSON snapshot of the seven editable prose fields |
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 |
task_mapping
One logged unit of work from the core tracker, mapped to a foundation
(migration 006).
| Column | Type | Notes |
|---|---|---|
id |
string | UUID primary key |
user_id |
string | FK to users.id, indexed |
external_task_id |
string | Opaque task id from the core tracker (not an FK) |
task_label |
string nullable | Human label of the task |
foundation |
string | love/strength/mission/vocation/short_term/long_term, indexed |
minutes |
integer | Time logged toward it |
occurred_at |
datetime | When the work happened, indexed |
created_at |
datetime | UTC |
Extraction Details
DiscoveryExtractor is intentionally responsible for plumbing, not business
logic hidden elsewhere. It:
- Validates that
ANTHROPIC_API_KEYexists. - Builds one user message from the seven responses.
- Calls Anthropic with
SYSTEM_PROMPT. - Parses the model response as JSON.
- Strips Markdown code fences if present.
- Validates that all required top-level keys exist.
- Validates that
confidencecontainstriad,type,variant, andikigai. - Retries once with an explicit JSON-only reminder if the first response cannot be parsed or is missing required keys.
- Raises
DiscoveryExtractionErrorif the API call fails or retry also fails.
Default extraction settings:
| Setting | Value |
|---|---|
| Default model | claude-sonnet-4-6 |
| Max tokens | 2000 |
| Required response type | Single JSON object |
| Retry count | One retry after invalid JSON or missing keys |
The system prompt instructs the model to infer:
- Enneagram triad and type from emotional center signals
- instinctual variant from recurring attention patterns
- Ikigai love, strength, mission, and vocation from repeated story themes
- confidence levels based on consistency and strength of evidence
Frontend Behavior
The frontend is static HTML with embedded JavaScript, served from /static.
The browser session is a server-set httpOnly cookie (issued by the OAuth
callback), so the pages hold no tokens of their own.
auth.js (shared helper):
authedFetch()sends every request withcredentials: "include"so the session cookies ride along- on a
401it makes a single silentPOST /api/auth/refresh(the refresh cookie is scoped to/api/auth) and retries the original request - if the refresh also fails, it redirects to
GET /api/auth/login— which is also how an unauthenticated first visit gets bounced through Google sign-in
discovery.html:
- shows the seven prompts one at a time (five discovery prompts plus near-term and long-term goal prompts)
- keeps answers in memory while navigating back and next
- starts a conversation on page load via
authedFetch("/discovery/start") - saves all responses on submit (
PUT /discovery/{id}/respond), then triggers extraction (POST /discovery/{id}/complete) - redirects to
/static/profile.htmlon success — no user id in the URL, since the backend derives the user from the session cookie - shows an error box and reload button if submission fails
profile.html:
- fetches the authenticated user's newest profile via
GET /discovery/profile/me - escapes all model-generated text before rendering
- shows the overlap narrative, Ikigai cards, a triad description, and the person's near-term/long-term goals, with confidence dots for triad and Ikigai
- while the profile is unlocked, offers two actions: "Edit my words" and "This is me"
- edit mode turns the narrative, the four Ikigai summaries, and both goal
fields into textareas and saves with
PATCH /discovery/profile/me; the AI's structural read (triad/type/wing/variant) is shown but not editable here - "This is me" locks the profile with
PUT /discovery/profile/me/confirm; a locked profile shows the confirmed state and no longer offers edit - links to
reflect.html("talk it through with your coach") while unlocked
reflect.html (Phase 2 AI-coach reflection):
- loads the dialogue + profile via
GET /discovery/profile/me/reflection, and auto-starts the coach's opening reflection when the thread is empty - shows a chat thread (coach / person bubbles) plus a live profile summary that refreshes when the coach applies a revision
- each turn posts to
POST /discovery/profile/me/reflect; whenrevisedis 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")
dashboard.html (Phase 4 goal dashboard):
- reads
GET /discovery/integration/work-patterns?days=Nand renders minutes and share per foundation as bars, with a selectable window - surfaces foundations with no logged time and asks whether that matches where the person wants their energy — it observes, it does not prescribe
- linked from
profile.html("Where your time goes")
visuals.html (Phase 5 visualizations):
- reads
GET /discovery/profile/meand renders an Ikigai four-circle Venn and an Enneagram diagram in SVG (callouts stay in plain language) - reads
GET /discovery/profile/me/goal-historyand shows how the near- and long-term goals have changed over time - linked from
profile.html("See your profile visualized")
Configuration
Populate .env with at minimum the Anthropic key and the auth-related
secrets:
ANTHROPIC_API_KEY=your-key-here
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxx
JWT_SECRET=random-64-char-string
IMPACTFLOW_API_KEY=random-64-char-string
Environment variables:
| Env var | Default | Notes |
|---|---|---|
ANTHROPIC_API_KEY |
required | Used by DiscoveryExtractor |
DATABASE_URL |
sqlite+aiosqlite:///./data/discovery.db |
Async SQLAlchemy database URL |
HOST_BIND_IP |
0.0.0.0 |
Docker host IP for publishing port 8011; use this when Docker runs inside WSL |
HOST_PORT |
8011 |
WSL/Docker host port; Windows portproxy exposes the same port |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
Override extraction model |
GOOGLE_CLIENT_ID |
required for browser auth | OAuth 2.0 client id from Google Cloud Console |
GOOGLE_CLIENT_SECRET |
required for browser auth | OAuth 2.0 client secret |
OAUTH_REDIRECT_URI |
derived from request | Override the callback URL Google redirects to; must be registered in the Cloud Console |
JWT_SECRET |
required | HS256 signing key for access tokens; also used for the OAuth state session cookie |
JWT_ACCESS_MINUTES |
15 |
Access-token lifetime |
JWT_REFRESH_DAYS |
7 |
Refresh-token lifetime |
COOKIE_SECURE |
true |
Set false for local http://localhost dev (Secure cookies aren't sent over plain http); must be true in production over HTTPS |
POST_LOGIN_REDIRECT |
/static/discovery.html |
Path the OAuth callback redirects to after setting the session cookies |
IMPACTFLOW_API_KEY |
required for MCP/machine auth | Header value for X-API-Key; resolves to the synthetic admin user |
CORS_ALLOWED_ORIGINS |
http://localhost:8011 |
Comma-separated allow-list of browser origins |
ALLOWED_EMAIL_DOMAINS |
empty (any) | Comma-separated allow-list of email domains; empty means accept any verified Google email |
app/database.py creates the SQLite directory automatically when the URL uses
a local SQLite file path.
Production deployment
The service is deployed (Docker Compose on the ci-gpu host) behind TLS at:
https://impactflow.teamci.org:8011
The container reads its env from .env (env_file in docker-compose.yml),
which is gitignored and lives only on the host — it does not travel with the
repo. For this HTTPS deployment the env must set:
OAUTH_REDIRECT_URI=https://impactflow.teamci.org:8011/api/auth/callback(and the same URI registered as an Authorized redirect URI on the Google OAuth client), andCOOKIE_SECURE=trueso session cookies carry theSecureflag.
After changing .env, apply with docker compose up -d (recreates the
container with the new env).
Weekly check-in cron. scripts/run-weekly-checkins.sh reads the admin API
key from .env and POSTs /discovery/coaching/run (the Phase 3 batch). Install
it as a weekly cron on the host:
( crontab -l 2>/dev/null | grep -v run-weekly-checkins.sh; \
echo '0 8 * * 1 /mnt/c/syncdata/impactflow-discovery/scripts/run-weekly-checkins.sh >/dev/null 2>&1' ) | crontab -
Running weekly services all cadences — the endpoint skips users who are not yet
due (biweekly/monthly), so a weekly tick is the finest needed. Output is
appended to data/coaching-cron.log. (On WSL, cron only runs while WSL is up.)
Quick Start With Docker
This path requires Docker Desktop or another Docker Engine installation with
the Compose plugin available as docker compose.
If Docker is installed inside WSL, run these commands from your WSL shell and change into the Windows-mounted project directory first:
cd /mnt/c/SyncData/impactflow-discovery
cp .env.example .env
# edit .env: ANTHROPIC_API_KEY (+ GOOGLE_*/JWT_SECRET/IMPACTFLOW_API_KEY for auth);
# set COOKIE_SECURE=false for local http dev
docker compose up -d --build discovery
On startup the container runs migrations then uvicorn
(python -m app.migration_bootstrap && alembic upgrade head && uvicorn …), so
the SQLite schema is brought up to the latest revision automatically.
Manage the running container:
docker compose ps # status
docker compose logs -f discovery # follow logs
docker compose restart discovery # restart
docker compose up -d --build discovery # apply .env or code changes
docker compose down # stop & remove
Open:
- Deployed (HTTPS): https://impactflow.teamci.org:8011
- Local (from Windows): http://localhost:8011 — redirects to the discovery flow
- Health check: http://localhost:8011/health
- From WSL,
localhostdoes not forward in; use the WSL IP (e.g.http://192.168.245.52:8011) or the Windows host gateway.
When Docker runs inside WSL and you want the app to behave like the other
WSL-published services, keep HOST_BIND_IP=0.0.0.0 in .env, then create a
Windows portproxy from an elevated PowerShell prompt:
powershell -ExecutionPolicy Bypass -File .\scripts\setup-wsl-bridge.ps1
That creates an entry like 0.0.0.0 8011 -> <WSL-IP> 8011, so the app is
available on localhost, LAN addresses, and the Tailscale address
100.103.206.4 without colliding with any existing WSL service on port 8001.
If you cannot run an elevated PowerShell prompt, use the non-admin TCP bridge fallback while the app is running:
powershell -ExecutionPolicy Bypass -File .\scripts\start-tcp-bridge.ps1
Keep that window open for as long as you need http://100.103.206.4:8011 to
forward to the app.
The SQLite file lives in ./data/discovery.db and persists across restarts
because docker-compose.yml mounts ./data into the container.
Local Development
Windows:
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt
cp .env.example .env
alembic upgrade head
uvicorn app.main:app --reload --port 8011
macOS/Linux:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
alembic upgrade head
uvicorn app.main:app --reload --port 8011
Tests And Verification
All Anthropic-backed services (extractor, reflection coach, check-in coach, foundation tagger) are mocked in the suite, so the tests need no real API key or network access:
.venv/Scripts/python.exe -m pytest # Windows venv
# or, on macOS/Linux: pytest
If the local Python toolchain is unavailable (e.g. the WSL↔Windows interop is down), run the suite inside the image, which has every dependency:
docker build -t ifd-test .
docker run --rm -v "$PWD:/app" --entrypoint python ifd-test -m pytest -q
smoke_test.py drives the whole API in process with httpx.ASGITransport and
patches DiscoveryExtractor so it also avoids a real Anthropic call:
.venv/Scripts/python.exe smoke_test.py
The smoke test verifies:
/health- unauthenticated requests return
401 X-API-Keyresolves to the synthetic admin user via/api/me- a malformed bearer token is rejected
/api/auth/loginredirects toaccounts.google.com- conversation creation, response saving, conversation fetching
- profile completion, fetching, and confirmation
- the activity log captures the calls and tags them
source=mcp /api/me/statsreports the correct conversation, profile, and locked counts- key
404paths
Error Handling And Edge Cases
The backend currently handles:
| Scenario | Result |
|---|---|
| Missing conversation on respond, complete, or fetch | 404 |
| Completing a conversation with all blank answers | 400 |
| Missing Anthropic API key | 502 from complete route |
| Anthropic transport or SDK error | 502 from complete route |
| Invalid model JSON on first try | One retry |
| Invalid model JSON after retry | 502 from complete route |
| Missing profile for user | 404 |
The frontend currently handles submission and profile-load failures by showing a simple error box. It does not persist partially typed answers across a full page reload, except for the browser's normal form restoration behavior.
Integration Notes
This service is ready to be called from a larger ImpactFlow app. The
authenticated user identity comes from Google OAuth (httpOnly session cookies)
on the browser side and X-API-Key on the machine-to-machine side; the
per-call user_id body parameter is gone.
Likely integration points:
- route users into
/static/discovery.html(or recreate the flow in the main UI); the session cookie set by/api/auth/callbackis sent automatically on subsequent calls, andauth.jshandles silent token refresh on401 - the MCP server should send
X-API-Key: $IMPACTFLOW_API_KEYon every request — no OAuth dance needed - use
lockedas the user's confirmation signal, andPATCH /discovery/profile/meif the surrounding app wants its own edit affordance - decide whether future profiles should supersede locked profiles or be versioned in the main product experience
Privacy And Data Notes
The app stores personal narrative answers and model-generated personality
summaries in SQLite. Treat data/discovery.db as sensitive user data.
Do not commit:
.env- real API keys
- production SQLite databases
- exported user response data
The repository includes test fixtures with synthetic responses for extractor tests.
Common Changes
When changing prompts:
- Update the prompt text in
app/static/discovery.html. - Keep the request body keys aligned with
RespondRequestinapp/schemas.py. - Update
PROMPT_LABELSinapp/services/extractor.pyif the extraction labels should change. - Adjust tests or fixtures if the extractor prompt expectations change.
When changing the profile schema:
- Update
app/schemas.py. - Update
app/models.py. - Add a new Alembic migration.
- Update
REQUIRED_KEYSandSYSTEM_PROMPTinapp/services/extractor.py. - Update
app/routers/discovery.pymapping logic. - Update
app/static/profile.htmlrendering. - Add or update tests.
When changing extraction behavior:
- Update
SYSTEM_PROMPTinapp/services/extractor.py. - Keep the JSON contract explicit.
- Update
REQUIRED_KEYSorREQUIRED_CONFIDENCE_KEYSonly when the response contract changes. - Add extractor tests for parsing, retry, or validation changes.
Known Limitations
- The static discovery page keeps in-progress answers in memory only.
- The static profile page always loads the newest profile for a user.
- Confirming a profile does not prevent a newer profile from being generated.
- The database is SQLite by default and intended for standalone/local service operation.
- Extraction quality depends on model output and the clarity of the user's stories.
- The system prompt asks for structured interpretation, but personality results should be treated as reflective guidance rather than clinical or diagnostic truth.