Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.
Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:
- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
/api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
/api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log
Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ImpactFlow Self-Discovery Module
ImpactFlow Self-Discovery is a standalone FastAPI service that turns five
short reflection stories into a structured Enneagram + Ikigai 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 JWT + refresh token and the browser stores the access JWT for subsequent requests. - With the JWT in the
Authorization: Bearer …header, the browser starts a discovery conversation withPOST /discovery/start— the backend derives theuser_idfrom the JWT, not from the request body. - The user answers five open-ended prompts.
- The browser saves all five 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, 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/meand lets the user lock it withPUT /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 + JWT for browser auth, and Anthropic for the analysis step.
What The App Does
The module collects five narrative 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 |
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 |
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 + |
| Bearer JWT |
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 five-prompt flow |
app/static/profile.html |
Browser-based profile display and confirm action |
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 |
tests/test_extractor.py |
Unit tests for extraction plumbing and retry behavior |
tests/test_auth.py |
Tests for the dual-auth dependency, JWT minting/decode, admin enforcement, and domain allow-list |
tests/test_migration_bootstrap.py |
Unit tests for the pre-Alembic SQLite stamping helper |
tests/test_static_discovery.py |
Guard test for the insecure-context UUID fallback in discovery.html |
tests/fixtures/{gut,head,heart}_type_responses.json |
Synthetic five-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 API supports two ways to authenticate, both resolved by a single
get_current_user dependency in app/auth.py:
| Caller | Mechanism | Notes |
|---|---|---|
| Browser users | Google OAuth + signed JWT in Authorization: Bearer … |
Issued by /api/auth/callback after a successful Google sign-in |
| Machine-to-machine (MCP server, scripts) | 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.env:http://localhost:8000/api/auth/callbackfor local devhttp://<deploy-host>:8000/api/auth/callbackfor the deployed instance
-
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 (Bearer JWT 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 after the browser has a JWT from the OAuth callback, and retries on submit if the first start call failed.
3. Saving Answers
PUT /discovery/{conversation_id}/respond accepts all five prompt responses:
{
"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..."
}
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, and future, then calls DiscoveryExtractor.extract().
The route rejects completion with:
404if the conversation does not exist400if all five 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. Confirming The Profile
PUT /discovery/profile/me/confirm locks the newest profile by setting
locked = true.
This is the current confirmation mechanism for "This is me" on the profile page. It does not prevent future conversations from generating newer profiles.
API Reference
All /discovery/*, /api/me*, /api/activity*, and /api/admin/* routes
require authentication (Bearer JWT or X-API-Key). /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; issues {access_token, refresh_token, user} |
POST |
/api/auth/refresh |
public (token in body) | Exchange refresh token for a new access token |
POST |
/api/auth/logout |
public (token in body) | Revoke a refresh token |
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 five 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 |
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 five-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 |
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 |
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:
- Validates that
ANTHROPIC_API_KEYexists. - Builds one user message from the five 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-5 |
| 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.
discovery.html:
- stores a generated
impactflow_user_idinlocalStorage - generates that id via
createUserId(), which preferscrypto.randomUUID()when available and falls back tocrypto.getRandomValues()so the flow still works in insecure contexts (e.g. plainhttp://over LAN) - shows five prompts one at a time
- keeps answers in memory while navigating back and next
- starts a conversation on page load
- saves all responses on submit
- triggers extraction
- redirects to the profile page on success
- shows an error box and reload button if submission fails
profile.html:
- reads
user_idfrom the query string - fetches the newest profile for that user
- escapes all model-generated text before rendering
- shows Ikigai cards and a triad description
- uses confidence dots for triad and Ikigai confidence
- sends the confirm request when the user clicks "This is me"
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-5 |
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 |
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:8000 |
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:
- Discovery flow: http://100.103.206.4:8011/static/discovery.html
- Health check: http://100.103.206.4:8011/health
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-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 now comes from Google OAuth 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) and rely on the JWT issued by/api/auth/callbackfor subsequent calls - the static frontend still needs to be updated to consume the new auth flow
(read the JWT from the callback response, store it, and send it as a
Bearerheader on every/discovery/*call) - the MCP server should send
X-API-Key: $IMPACTFLOW_API_KEYon every request — no OAuth dance needed - use
lockedas the user's confirmation signal - 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.