Files
impactflow_discovery/README.md
T
Joel Salmon b8f176bb31 Initial commit: ImpactFlow Discovery + Google OAuth auth layer
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>
2026-05-27 10:59:41 -05:00

724 lines
30 KiB
Markdown

# 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:
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 JWT + refresh token and the
browser stores the access JWT for subsequent requests.
3. With the JWT in the `Authorization: Bearer …` header, the browser starts
a discovery conversation with `POST /discovery/start` — the backend
derives the `user_id` from the JWT, not from the request body.
4. The user answers five open-ended prompts.
5. The browser saves all five 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, 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` and lets the user 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 + 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
```text
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
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:8000/api/auth/callback` for local dev
- `http://<deploy-host>:8000/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`:
```bash
.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 (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_id` set to the authenticated user's id
- `started_at` in UTC
- empty prompt fields
It returns:
```json
{
"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:
```json
{
"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:
```json
{
"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:
- `404` if the conversation does not exist
- `400` if all five 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. 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:
1. Validates that `ANTHROPIC_API_KEY` exists.
2. Builds one user message from the five 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-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_id` in `localStorage`
- generates that id via `createUserId()`, which prefers `crypto.randomUUID()`
when available and falls back to `crypto.getRandomValues()` so the flow
still works in insecure contexts (e.g. plain `http://` 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_id` from 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:
```text
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:
```bash
cd /mnt/c/SyncData/impactflow-discovery
```
```bash
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
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
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:
```bash
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:
```bash
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:
```bash
.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:
```bash
.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 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/callback` for 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
`Bearer` header on every `/discovery/*` call)
- 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
- 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.