mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:20:36 +00:00
b8f176bb31
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>
724 lines
30 KiB
Markdown
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.
|