mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:20:36 +00:00
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>
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user