Files
impactflow_discovery/README.md
T
Joel Salmon 33674f92f4 Complete Phase 1: goals, cookie auth, profile editing
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with
the auth layer.

Goals (5 -> 7 prompts):
- Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw
  text on the conversation and store AI-articulated goal summaries on the
  profile. Extractor articulates the person's own stated goals (mirror,
  not compass) and never fabricates. Alembic 003 adds the four columns.

Cookie-based browser sessions (fixes frontend<->auth desync):
- OAuth callback now sets httpOnly session cookies and redirects into the
  app instead of returning JSON. get_current_user gains a cookie fallback
  (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie
  and set/clear cookies. New shared auth.js (authedFetch) sends cookies and
  silently refreshes on 401. Static pages drop the bogus user_id and call
  the correct /me endpoints.

Profile editing (read/edit/affirm):
- PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap
  narrative, goals); owner-scoped, partial update, 409 when locked. Edit
  mode in profile.html with Save/Cancel.

Also: bump default model to claude-sonnet-4-6, align ports to 8011
(OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and
refresh the README to match the shipped behavior.

Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases;
factored a shared app_client fixture into conftest.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:26:08 -05:00

34 KiB
Raw Blame History

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:

  1. A user opens /static/discovery.html.
  2. The browser sends them through Google sign-in at GET /api/auth/login; GET /api/auth/callback mints 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.
  3. The browser starts a discovery conversation with POST /discovery/start — the backend derives the user_id from the session cookie, not the body.
  4. The user answers seven open-ended prompts: five discovery prompts plus a near-term (612 month) and a long-term (35 year) goal prompt.
  5. The browser saves all answers with PUT /discovery/{conversation_id}/respond.
  6. The browser asks the backend to analyze the saved answers with POST /discovery/{conversation_id}/complete.
  7. The backend calls Anthropic through DiscoveryExtractor.
  8. The extractor asks for JSON containing Enneagram, instinctual variant, Ikigai summaries, articulated short- and long-term goals, confidence flags, and optional extraction notes.
  9. The backend stores that JSON as a DiscoveryProfile row owned by the authenticated user.
  10. The browser redirects to /static/profile.html.
  11. The profile page loads the newest profile with GET /discovery/profile/me. While it is unlocked the user can revise their words with PATCH /discovery/profile/me, then lock it with PUT /discovery/profile/me/confirm.

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 (612 month) goals, articulated back
prompt_goals_long The Long Horizon The person's own long-term (35 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 (612 month) goals, articulated back
long_term_goals The user's own long-term (35 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
app/static/auth.js Shared authedFetch helper: sends session cookies, silently refreshes on 401, redirects to login
app/static/style.css Shared UI styling
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
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_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_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

  1. In https://console.cloud.google.com open APIs & Services → Credentials.

  2. Create an OAuth 2.0 Client ID of type Web application.

  3. Add authorized redirect URIs that match OAUTH_REDIRECT_URI in .env:

    • http://localhost:8011/api/auth/callback for local dev
    • http://<deploy-host>:8011/api/auth/callback for the deployed instance
  4. Copy the client id and client secret into .env as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.

  5. Generate two random 64-character strings and put them in .env as JWT_SECRET and IMPACTFLOW_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:

  1. Calls Base.metadata.create_all() as a local development safety net. Alembic remains the source of truth for schema changes.
  2. Calls ensure_api_key_admin() so the synthetic admin user backing X-API-Key exists before the first request arrives.
  3. 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:

  1. python -m app.migration_bootstrap — if data/discovery.db already exists with the app tables but no alembic_version row (older local DBs created via create_all before Alembic existed), stamp it as revision 001 so step 2 does not try to recreate existing tables.
  2. alembic upgrade head — apply any outstanding migrations.
  3. 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_id set to the authenticated user's id
  • started_at in 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 612 months I want to...",
  "prompt_goals_long": "In the next 35 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:

  • 404 if the conversation does not exist
  • 400 if all seven responses are blank
  • 502 if 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:

  • 404 if the user has no profile
  • 409 if the profile is locked (affirming makes it final)
  • 400 if 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.

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
GET /discovery/conversation/{conversation_id} yes Fetch stored conversation responses (owner only)

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 (612 month) goal response
prompt_goals_long text nullable Long-term (35 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 (612 month) goals
long_term_goals text nullable Articulated long-term (35 year) goals
confidence_json text nullable JSON string for confidence flags
locked boolean Defaults to false

Extraction Details

DiscoveryExtractor is intentionally responsible for plumbing, not business logic hidden elsewhere. It:

  1. Validates that ANTHROPIC_API_KEY exists.
  2. Builds one user message from the seven responses.
  3. Calls Anthropic with SYSTEM_PROMPT.
  4. Parses the model response as JSON.
  5. Strips Markdown code fences if present.
  6. Validates that all required top-level keys exist.
  7. Validates that confidence contains triad, type, variant, and ikigai.
  8. Retries once with an explicit JSON-only reminder if the first response cannot be parsed or is missing required keys.
  9. Raises DiscoveryExtractionError if 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 with credentials: "include" so the session cookies ride along
  • on a 401 it makes a single silent POST /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.html on 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

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.

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 and add ANTHROPIC_API_KEY
docker compose up --build

Open:

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

The extractor tests mock Anthropic, so they do not need a real API key or network access:

.venv/Scripts/python.exe -m pytest

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-Key resolves to the synthetic admin user via /api/me
  • a malformed bearer token is rejected
  • /api/auth/login redirects to accounts.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/stats reports the correct conversation, profile, and locked counts
  • key 404 paths

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/callback is sent automatically on subsequent calls, and auth.js handles silent token refresh on 401
  • the MCP server should send X-API-Key: $IMPACTFLOW_API_KEY on every request — no OAuth dance needed
  • use locked as the user's confirmation signal, and PATCH /discovery/profile/me if 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:

  1. Update the prompt text in app/static/discovery.html.
  2. Keep the request body keys aligned with RespondRequest in app/schemas.py.
  3. Update PROMPT_LABELS in app/services/extractor.py if the extraction labels should change.
  4. Adjust tests or fixtures if the extractor prompt expectations change.

When changing the profile schema:

  1. Update app/schemas.py.
  2. Update app/models.py.
  3. Add a new Alembic migration.
  4. Update REQUIRED_KEYS and SYSTEM_PROMPT in app/services/extractor.py.
  5. Update app/routers/discovery.py mapping logic.
  6. Update app/static/profile.html rendering.
  7. Add or update tests.

When changing extraction behavior:

  1. Update SYSTEM_PROMPT in app/services/extractor.py.
  2. Keep the JSON contract explicit.
  3. Update REQUIRED_KEYS or REQUIRED_CONFIDENCE_KEYS only when the response contract changes.
  4. 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.