mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:30:35 +00:00
Phase 2: AI coach reflection loop
Add the mirror-not-compass reflection layer between profile generation and
affirmation. The coach reflects the person's profile back, and only when they
explicitly correct or add something does it propose revisions in their own
direction — never prescribing goals.
- ReflectionCoach service (app/services/reflector.py): Anthropic-backed,
returns {message, revisions, revision_note}; revisions filtered to the seven
editable prose fields (never triad/type); one-retry JSON handling.
- Endpoints (owner-scoped, 409 when locked): POST /discovery/profile/me/reflect
(opener + turns, applies revisions), GET .../reflection (dialogue),
GET .../revisions (iteration history). complete records an 'extraction'
revision; PATCH records 'manual_edit'.
- Models + migration 004: reflection_message (coach/person turns) and
profile_revision (snapshots: extraction | reflection | manual_edit) —
captures edits and iterations rather than overwriting.
- Frontend: reflect.html chat (coach/person bubbles, live profile summary that
refreshes on revision, affirm); linked from profile.html.
- Affirmation remains the existing confirm/lock.
Also refresh README for Phase 2 and for the HTTPS deployment
(https://impactflow.teamci.org:8011, OAUTH_REDIRECT_URI + COOKIE_SECURE notes).
Tests: 50 passing (added reflector unit tests and reflection endpoint tests;
run in-container).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,10 @@ If you need to explain this app in detail, use this mental model:
|
|||||||
While it is unlocked the user can revise their words with
|
While it is unlocked the user can revise their words with
|
||||||
`PATCH /discovery/profile/me`, then lock it with
|
`PATCH /discovery/profile/me`, then lock it with
|
||||||
`PUT /discovery/profile/me/confirm`.
|
`PUT /discovery/profile/me/confirm`.
|
||||||
|
12. Optionally (Phase 2) the user opens `/static/reflect.html` and refines the
|
||||||
|
profile through an AI-coach reflection loop (`POST /discovery/profile/me/reflect`)
|
||||||
|
— the coach mirrors the profile back and applies the person's own
|
||||||
|
corrections — before affirming with the same confirm/lock.
|
||||||
|
|
||||||
Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and
|
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
|
authenticate with `X-API-Key: $IMPACTFLOW_API_KEY` instead. That header
|
||||||
@@ -136,16 +140,21 @@ Important files:
|
|||||||
| `app/services/extractor.py` | Anthropic client wrapper, prompt, JSON parsing, retry logic |
|
| `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/migration_bootstrap.py` | Stamps pre-Alembic SQLite DBs as revision `001` so `alembic upgrade head` succeeds on older local databases |
|
||||||
| `app/static/discovery.html` | Browser-based seven-prompt flow |
|
| `app/static/discovery.html` | Browser-based seven-prompt flow |
|
||||||
| `app/static/profile.html` | Browser-based profile display, edit, and confirm actions |
|
| `app/static/profile.html` | Browser-based profile display, edit, and confirm actions; links to reflection |
|
||||||
|
| `app/static/reflect.html` | Phase 2 AI-coach reflection chat (mirror loop, applies revisions, affirm) |
|
||||||
| `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login |
|
| `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login |
|
||||||
| `app/static/style.css` | Shared UI styling |
|
| `app/static/style.css` | Shared UI styling |
|
||||||
|
| `app/services/reflector.py` | `ReflectionCoach`: Anthropic-backed mirror loop, JSON parsing, revision filtering |
|
||||||
| `alembic/versions/001_initial.py` | Initial database schema migration |
|
| `alembic/versions/001_initial.py` | Initial database schema migration |
|
||||||
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
|
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
|
||||||
| `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` |
|
| `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` |
|
||||||
|
| `alembic/versions/004_add_reflection.py` | Adds `reflection_message` and `profile_revision` tables (Phase 2) |
|
||||||
| `tests/conftest.py` | Shared `app_client` fixture (isolated app + temp DB) |
|
| `tests/conftest.py` | Shared `app_client` fixture (isolated app + temp DB) |
|
||||||
| `tests/test_extractor.py` | Unit tests for extraction plumbing, goals, and retry behavior |
|
| `tests/test_extractor.py` | Unit tests for extraction plumbing, goals, and retry behavior |
|
||||||
|
| `tests/test_reflector.py` | Unit tests for `ReflectionCoach` (mirror, revision filtering, retry) |
|
||||||
| `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list |
|
| `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list |
|
||||||
| `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) |
|
| `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) |
|
||||||
|
| `tests/test_reflection.py` | Tests for the reflection endpoints (turns, applied revisions, lock/`409`, history) |
|
||||||
| `tests/test_migration_bootstrap.py` | Unit tests for the pre-Alembic SQLite stamping helper |
|
| `tests/test_migration_bootstrap.py` | Unit tests for the pre-Alembic SQLite stamping helper |
|
||||||
| `tests/test_static_discovery.py` | Guard tests for the static pages' cookie-session and edit contract |
|
| `tests/test_static_discovery.py` | Guard tests for the static pages' cookie-session and edit contract |
|
||||||
| `tests/fixtures/{gut,head,heart}_type_responses.json` | Synthetic seven-prompt responses used by extractor tests |
|
| `tests/fixtures/{gut,head,heart}_type_responses.json` | Synthetic seven-prompt responses used by extractor tests |
|
||||||
@@ -171,9 +180,11 @@ every subsequent user defaults to `role=user`. Admin-only routes (e.g.
|
|||||||
|
|
||||||
1. In <https://console.cloud.google.com> open APIs & Services → Credentials.
|
1. In <https://console.cloud.google.com> open APIs & Services → Credentials.
|
||||||
2. Create an **OAuth 2.0 Client ID** of type **Web application**.
|
2. Create an **OAuth 2.0 Client ID** of type **Web application**.
|
||||||
3. Add authorized redirect URIs that match `OAUTH_REDIRECT_URI` in `.env`:
|
3. Add authorized redirect URIs that match `OAUTH_REDIRECT_URI` in `.env`
|
||||||
- `http://localhost:8011/api/auth/callback` for local dev
|
exactly (scheme, host, port, path — Google treats `localhost` and
|
||||||
- `http://<deploy-host>:8011/api/auth/callback` for the deployed instance
|
`127.0.0.1` as distinct):
|
||||||
|
- `http://localhost:8011/api/auth/callback` for local http dev (set `COOKIE_SECURE=false`)
|
||||||
|
- `https://impactflow.teamci.org:8011/api/auth/callback` for the deployed instance (HTTPS; set `COOKIE_SECURE=true`)
|
||||||
4. Copy the client id and client secret into `.env` as `GOOGLE_CLIENT_ID` and
|
4. Copy the client id and client secret into `.env` as `GOOGLE_CLIENT_ID` and
|
||||||
`GOOGLE_CLIENT_SECRET`.
|
`GOOGLE_CLIENT_SECRET`.
|
||||||
5. Generate two random 64-character strings and put them in `.env` as
|
5. Generate two random 64-character strings and put them in `.env` as
|
||||||
@@ -354,6 +365,27 @@ It rejects with:
|
|||||||
once locked, the profile can no longer be edited (`PATCH` returns `409`). It
|
once locked, the profile can no longer be edited (`PATCH` returns `409`). It
|
||||||
does not prevent future conversations from generating newer profiles.
|
does not prevent future conversations from generating newer profiles.
|
||||||
|
|
||||||
|
### 8. Reflecting With The Coach (Phase 2)
|
||||||
|
|
||||||
|
The AI-coach reflection loop lets the person refine their profile through a
|
||||||
|
conversation before affirming it. The coach is a **mirror, not a compass**: it
|
||||||
|
reflects the profile back, asks whether it fits, and — only when the person
|
||||||
|
explicitly corrects or adds something — proposes revised text in the person's
|
||||||
|
own direction. It never prescribes goals or invents direction. Affirmation is
|
||||||
|
still the `/confirm` lock above; the loop is what happens before it.
|
||||||
|
|
||||||
|
- `POST /discovery/profile/me/reflect` advances the loop by one turn. An empty
|
||||||
|
`message` starts it (the coach's opening reflection); a non-empty `message`
|
||||||
|
is recorded as the person's turn before the coach replies. When the coach
|
||||||
|
proposes revisions, they are applied to the editable prose fields (never the
|
||||||
|
structural Enneagram read) and snapshotted. Returns the coach turn, the
|
||||||
|
(possibly revised) profile, and `revised`/`revision_note`. `409` once locked.
|
||||||
|
- `GET /discovery/profile/me/reflection` returns the dialogue (ordered
|
||||||
|
`coach`/`person` turns) plus the current profile.
|
||||||
|
- `GET /discovery/profile/me/revisions` returns the profile's edit/iteration
|
||||||
|
history, newest first. Every change is snapshotted in `profile_revision`
|
||||||
|
with a `source` of `extraction` (initial), `reflection`, or `manual_edit`.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
|
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
|
||||||
@@ -385,6 +417,9 @@ clients.) `/api/auth/login`, `/api/auth/callback`, `/health`, `/`, and
|
|||||||
| `GET` | `/discovery/profile/me` | yes | Fetch newest profile for the authenticated user |
|
| `GET` | `/discovery/profile/me` | yes | Fetch newest profile for the authenticated user |
|
||||||
| `PATCH` | `/discovery/profile/me` | yes | Edit the newest profile's prose (`409` if locked) |
|
| `PATCH` | `/discovery/profile/me` | yes | Edit the newest profile's prose (`409` if locked) |
|
||||||
| `PUT` | `/discovery/profile/me/confirm` | yes | Lock newest profile for the authenticated user |
|
| `PUT` | `/discovery/profile/me/confirm` | yes | Lock newest profile for the authenticated user |
|
||||||
|
| `POST` | `/discovery/profile/me/reflect` | yes | Advance the AI-coach reflection loop (Phase 2); applies revisions (`409` if locked) |
|
||||||
|
| `GET` | `/discovery/profile/me/reflection` | yes | The reflection dialogue for the latest profile |
|
||||||
|
| `GET` | `/discovery/profile/me/revisions` | yes | Profile edit/iteration history (newest first) |
|
||||||
| `GET` | `/discovery/conversation/{conversation_id}` | yes | Fetch stored conversation responses (owner only) |
|
| `GET` | `/discovery/conversation/{conversation_id}` | yes | Fetch stored conversation responses (owner only) |
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
@@ -477,6 +512,35 @@ Stores one extracted profile for one conversation.
|
|||||||
| `confidence_json` | text nullable | JSON string for confidence flags |
|
| `confidence_json` | text nullable | JSON string for confidence flags |
|
||||||
| `locked` | boolean | Defaults to false |
|
| `locked` | boolean | Defaults to false |
|
||||||
|
|
||||||
|
### `reflection_message`
|
||||||
|
|
||||||
|
One turn in the Phase 2 AI-coach reflection loop (migration `004`).
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | string | UUID primary key |
|
||||||
|
| `profile_id` | string | FK to `discovery_profile.id`, indexed |
|
||||||
|
| `user_id` | string | FK to `users.id`, indexed |
|
||||||
|
| `role` | string | `coach` (AI mirror) or `person` (the human) |
|
||||||
|
| `content` | text | The turn's text |
|
||||||
|
| `sequence` | integer | Monotonic order within a profile's thread |
|
||||||
|
| `created_at` | datetime | UTC |
|
||||||
|
|
||||||
|
### `profile_revision`
|
||||||
|
|
||||||
|
A snapshot of a profile's editable prose at a point in time, so edits and
|
||||||
|
iterations are captured rather than overwritten (migration `004`).
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | string | UUID primary key |
|
||||||
|
| `profile_id` | string | FK to `discovery_profile.id`, indexed |
|
||||||
|
| `user_id` | string | FK to `users.id`, indexed |
|
||||||
|
| `source` | string | `extraction` (initial), `reflection`, or `manual_edit` |
|
||||||
|
| `fields_json` | text | JSON snapshot of the seven editable prose fields |
|
||||||
|
| `note` | text nullable | What changed (e.g. the coach's revision note) |
|
||||||
|
| `created_at` | datetime | UTC |
|
||||||
|
|
||||||
## Extraction Details
|
## Extraction Details
|
||||||
|
|
||||||
`DiscoveryExtractor` is intentionally responsible for plumbing, not business
|
`DiscoveryExtractor` is intentionally responsible for plumbing, not business
|
||||||
@@ -550,6 +614,17 @@ callback), so the pages hold no tokens of their own.
|
|||||||
structural read (triad/type/wing/variant) is shown but not editable here
|
structural read (triad/type/wing/variant) is shown but not editable here
|
||||||
- "This is me" locks the profile with `PUT /discovery/profile/me/confirm`; a
|
- "This is me" locks the profile with `PUT /discovery/profile/me/confirm`; a
|
||||||
locked profile shows the confirmed state and no longer offers edit
|
locked profile shows the confirmed state and no longer offers edit
|
||||||
|
- links to `reflect.html` ("talk it through with your coach") while unlocked
|
||||||
|
|
||||||
|
`reflect.html` (Phase 2 AI-coach reflection):
|
||||||
|
|
||||||
|
- loads the dialogue + profile via `GET /discovery/profile/me/reflection`, and
|
||||||
|
auto-starts the coach's opening reflection when the thread is empty
|
||||||
|
- shows a chat thread (coach / person bubbles) plus a live profile summary that
|
||||||
|
refreshes when the coach applies a revision
|
||||||
|
- each turn posts to `POST /discovery/profile/me/reflect`; when `revised` is
|
||||||
|
true it updates the summary and notes what changed
|
||||||
|
- "This is me — affirm" locks the profile via the same confirm endpoint
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -588,6 +663,26 @@ Environment variables:
|
|||||||
`app/database.py` creates the SQLite directory automatically when the URL uses
|
`app/database.py` creates the SQLite directory automatically when the URL uses
|
||||||
a local SQLite file path.
|
a local SQLite file path.
|
||||||
|
|
||||||
|
### Production deployment
|
||||||
|
|
||||||
|
The service is deployed (Docker Compose on the `ci-gpu` host) behind TLS at:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://impactflow.teamci.org:8011
|
||||||
|
```
|
||||||
|
|
||||||
|
The container reads its env from `.env` (`env_file` in `docker-compose.yml`),
|
||||||
|
which is gitignored and lives only on the host — it does not travel with the
|
||||||
|
repo. For this HTTPS deployment the env must set:
|
||||||
|
|
||||||
|
- `OAUTH_REDIRECT_URI=https://impactflow.teamci.org:8011/api/auth/callback`
|
||||||
|
(and the same URI registered as an Authorized redirect URI on the Google
|
||||||
|
OAuth client), and
|
||||||
|
- `COOKIE_SECURE=true` so session cookies carry the `Secure` flag.
|
||||||
|
|
||||||
|
After changing `.env`, apply with `docker compose up -d` (recreates the
|
||||||
|
container with the new env).
|
||||||
|
|
||||||
## Quick Start With Docker
|
## Quick Start With Docker
|
||||||
|
|
||||||
This path requires Docker Desktop or another Docker Engine installation with
|
This path requires Docker Desktop or another Docker Engine installation with
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""add reflection_message and profile_revision (Phase 2 AI coach loop)
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
|
||||||
|
Phase 2: the AI-coach reflection loop stores its dialogue in reflection_message,
|
||||||
|
and every change to a profile's prose (initial extraction, reflection-applied
|
||||||
|
revision, or manual edit) is snapshotted in profile_revision.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "004"
|
||||||
|
down_revision: Union[str, None] = "003"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"reflection_message",
|
||||||
|
sa.Column("id", sa.String(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"profile_id",
|
||||||
|
sa.String(),
|
||||||
|
sa.ForeignKey("discovery_profile.id"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(),
|
||||||
|
sa.ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("role", sa.String(), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_reflection_message_profile_id",
|
||||||
|
"reflection_message",
|
||||||
|
["profile_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_reflection_message_user_id", "reflection_message", ["user_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"profile_revision",
|
||||||
|
sa.Column("id", sa.String(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"profile_id",
|
||||||
|
sa.String(),
|
||||||
|
sa.ForeignKey("discovery_profile.id"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(),
|
||||||
|
sa.ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("source", sa.String(), nullable=False),
|
||||||
|
sa.Column("fields_json", sa.Text(), nullable=False),
|
||||||
|
sa.Column("note", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_profile_revision_profile_id", "profile_revision", ["profile_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_profile_revision_user_id", "profile_revision", ["user_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_profile_revision_user_id", table_name="profile_revision"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_profile_revision_profile_id", table_name="profile_revision"
|
||||||
|
)
|
||||||
|
op.drop_table("profile_revision")
|
||||||
|
|
||||||
|
op.drop_index(
|
||||||
|
"ix_reflection_message_user_id", table_name="reflection_message"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_reflection_message_profile_id", table_name="reflection_message"
|
||||||
|
)
|
||||||
|
op.drop_table("reflection_message")
|
||||||
@@ -146,3 +146,45 @@ class DiscoveryProfile(Base):
|
|||||||
locked: Mapped[bool] = mapped_column(
|
locked: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=False
|
Boolean, nullable=False, default=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReflectionMessage(Base):
|
||||||
|
"""One turn in the Phase 2 AI-coach reflection loop. The coach mirrors the
|
||||||
|
profile back; the person reacts; iterate until they affirm (lock)."""
|
||||||
|
|
||||||
|
__tablename__ = "reflection_message"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
profile_id: Mapped[str] = mapped_column(
|
||||||
|
String, ForeignKey("discovery_profile.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# "coach" (AI mirror) or "person" (the human).
|
||||||
|
role: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# Monotonic order within a profile's reflection thread.
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileRevision(Base):
|
||||||
|
"""A snapshot of a profile's editable prose at a point in time, so edits
|
||||||
|
and iterations are captured rather than overwritten. source is one of
|
||||||
|
'extraction' (initial), 'reflection' (AI-coach loop), 'manual_edit'."""
|
||||||
|
|
||||||
|
__tablename__ = "profile_revision"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
profile_id: Mapped[str] = mapped_column(
|
||||||
|
String, ForeignKey("discovery_profile.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
source: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
# JSON snapshot of the seven editable prose fields at this revision.
|
||||||
|
fields_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|||||||
+216
-1
@@ -17,8 +17,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import DiscoveryConversation, DiscoveryProfile, User
|
from app.models import (
|
||||||
|
DiscoveryConversation,
|
||||||
|
DiscoveryProfile,
|
||||||
|
ProfileRevision,
|
||||||
|
ReflectionMessage,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor
|
from app.services.extractor import DiscoveryExtractionError, DiscoveryExtractor
|
||||||
|
from app.services.reflector import (
|
||||||
|
EDITABLE_FIELDS,
|
||||||
|
ReflectionCoach,
|
||||||
|
ReflectionError,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
||||||
|
|
||||||
@@ -27,6 +38,33 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_fields(profile: DiscoveryProfile) -> dict:
|
||||||
|
"""The seven editable prose fields as a plain dict (for snapshots and the
|
||||||
|
reflector's profile context)."""
|
||||||
|
return {f: getattr(profile, f) for f in EDITABLE_FIELDS}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_revision(
|
||||||
|
db: AsyncSession,
|
||||||
|
profile: DiscoveryProfile,
|
||||||
|
source: str,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Snapshot the profile's editable prose into profile_revision. Caller
|
||||||
|
commits."""
|
||||||
|
db.add(
|
||||||
|
ProfileRevision(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
profile_id=profile.id,
|
||||||
|
user_id=profile.user_id,
|
||||||
|
source=source,
|
||||||
|
fields_json=json.dumps(_profile_fields(profile)),
|
||||||
|
note=note,
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_profile_response(
|
def _to_profile_response(
|
||||||
profile: DiscoveryProfile, extraction_notes: str | None = None
|
profile: DiscoveryProfile, extraction_notes: str | None = None
|
||||||
) -> schemas.ProfileResponse:
|
) -> schemas.ProfileResponse:
|
||||||
@@ -182,6 +220,7 @@ async def complete_conversation(
|
|||||||
)
|
)
|
||||||
conversation.completed_at = _now()
|
conversation.completed_at = _now()
|
||||||
db.add(profile)
|
db.add(profile)
|
||||||
|
_record_revision(db, profile, source="extraction")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return _to_profile_response(
|
return _to_profile_response(
|
||||||
@@ -223,6 +262,7 @@ async def update_my_profile(
|
|||||||
raise HTTPException(status_code=400, detail="No fields to update")
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
for field, value in updates.items():
|
for field, value in updates.items():
|
||||||
setattr(profile, field, value)
|
setattr(profile, field, value)
|
||||||
|
_record_revision(db, profile, source="manual_edit", note="manual edit")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(profile)
|
await db.refresh(profile)
|
||||||
|
|
||||||
@@ -244,6 +284,181 @@ async def confirm_my_profile(
|
|||||||
return schemas.ConfirmResponse(status="locked")
|
return schemas.ConfirmResponse(status="locked")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Phase 2: AI coach reflection loop ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _reflection_history(
|
||||||
|
db: AsyncSession, profile_id: str
|
||||||
|
) -> list[ReflectionMessage]:
|
||||||
|
stmt = (
|
||||||
|
select(ReflectionMessage)
|
||||||
|
.where(ReflectionMessage.profile_id == profile_id)
|
||||||
|
.order_by(ReflectionMessage.sequence)
|
||||||
|
)
|
||||||
|
return list((await db.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def _msg_out(m: ReflectionMessage) -> schemas.ReflectionMessageOut:
|
||||||
|
return schemas.ReflectionMessageOut(
|
||||||
|
role=m.role,
|
||||||
|
content=m.content,
|
||||||
|
sequence=m.sequence,
|
||||||
|
created_at=m.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/profile/me/reflection", response_model=schemas.ReflectionThreadResponse
|
||||||
|
)
|
||||||
|
async def get_reflection(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""The reflection dialogue so far for the user's latest profile."""
|
||||||
|
profile = await _latest_profile(db, user.id)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||||
|
history = await _reflection_history(db, profile.id)
|
||||||
|
return schemas.ReflectionThreadResponse(
|
||||||
|
messages=[_msg_out(m) for m in history],
|
||||||
|
profile=_to_profile_response(profile),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/profile/me/reflect", response_model=schemas.ReflectTurnResponse
|
||||||
|
)
|
||||||
|
async def reflect_on_profile(
|
||||||
|
payload: schemas.ReflectRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Advance the AI-coach reflection loop by one turn.
|
||||||
|
|
||||||
|
An empty message starts the loop (the coach's opening reflection); a
|
||||||
|
non-empty message is recorded as the person's turn before the coach
|
||||||
|
replies. When the person's input implies a correction, the coach proposes
|
||||||
|
revisions which are applied to the profile (mirror, not compass) and
|
||||||
|
snapshotted. Affirming is the separate ``/confirm`` lock.
|
||||||
|
"""
|
||||||
|
profile = await _latest_profile(db, user.id)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||||
|
if profile.locked:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Profile is affirmed and locked; reflection is closed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
history = await _reflection_history(db, profile.id)
|
||||||
|
next_seq = (history[-1].sequence + 1) if history else 0
|
||||||
|
|
||||||
|
person_text = payload.message.strip()
|
||||||
|
if person_text:
|
||||||
|
db.add(
|
||||||
|
ReflectionMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
profile_id=profile.id,
|
||||||
|
user_id=profile.user_id,
|
||||||
|
role="person",
|
||||||
|
content=person_text,
|
||||||
|
sequence=next_seq,
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
next_seq += 1
|
||||||
|
elif history:
|
||||||
|
# No new message and the loop has already opened — nothing to do.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Provide a message to continue reflecting."
|
||||||
|
)
|
||||||
|
|
||||||
|
coach_history = [
|
||||||
|
{"role": m.role, "content": m.content} for m in history
|
||||||
|
]
|
||||||
|
if person_text:
|
||||||
|
coach_history.append({"role": "person", "content": person_text})
|
||||||
|
|
||||||
|
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||||
|
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||||
|
try:
|
||||||
|
coach = ReflectionCoach(api_key=api_key, model=model)
|
||||||
|
result = await coach.reflect(_profile_fields(profile) | {"triad": profile.triad}, coach_history)
|
||||||
|
except ReflectionError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
revised = False
|
||||||
|
revisions = result.get("revisions")
|
||||||
|
if revisions:
|
||||||
|
for field, value in revisions.items():
|
||||||
|
if field in EDITABLE_FIELDS:
|
||||||
|
setattr(profile, field, value)
|
||||||
|
revised = True
|
||||||
|
_record_revision(
|
||||||
|
db,
|
||||||
|
profile,
|
||||||
|
source="reflection",
|
||||||
|
note=result.get("revision_note") or "reflection revision",
|
||||||
|
)
|
||||||
|
|
||||||
|
coach_msg = ReflectionMessage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
profile_id=profile.id,
|
||||||
|
user_id=profile.user_id,
|
||||||
|
role="coach",
|
||||||
|
content=result["message"],
|
||||||
|
sequence=next_seq,
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
db.add(coach_msg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(profile)
|
||||||
|
await db.refresh(coach_msg)
|
||||||
|
|
||||||
|
return schemas.ReflectTurnResponse(
|
||||||
|
message=_msg_out(coach_msg),
|
||||||
|
profile=_to_profile_response(profile),
|
||||||
|
revised=revised,
|
||||||
|
revision_note=result.get("revision_note") if revised else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/profile/me/revisions",
|
||||||
|
response_model=list[schemas.ProfileRevisionOut],
|
||||||
|
)
|
||||||
|
async def get_profile_revisions(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""The profile's edit/iteration history, newest first."""
|
||||||
|
profile = await _latest_profile(db, user.id)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||||
|
stmt = (
|
||||||
|
select(ProfileRevision)
|
||||||
|
.where(ProfileRevision.profile_id == profile.id)
|
||||||
|
.order_by(ProfileRevision.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = (await db.execute(stmt)).scalars().all()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
fields = json.loads(r.fields_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
fields = {}
|
||||||
|
out.append(
|
||||||
|
schemas.ProfileRevisionOut(
|
||||||
|
id=r.id,
|
||||||
|
source=r.source,
|
||||||
|
fields=fields,
|
||||||
|
note=r.note,
|
||||||
|
created_at=r.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/conversation/{conversation_id}",
|
"/conversation/{conversation_id}",
|
||||||
response_model=schemas.ConversationResponse,
|
response_model=schemas.ConversationResponse,
|
||||||
|
|||||||
@@ -71,6 +71,43 @@ class ConfirmResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
# -- Phase 2: AI coach reflection loop ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ReflectRequest(BaseModel):
|
||||||
|
# Empty/omitted starts the loop (the coach's opening reflection).
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ReflectionMessageOut(BaseModel):
|
||||||
|
role: str # "coach" or "person"
|
||||||
|
content: str
|
||||||
|
sequence: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ReflectTurnResponse(BaseModel):
|
||||||
|
"""One coach turn, plus the (possibly revised) profile."""
|
||||||
|
|
||||||
|
message: ReflectionMessageOut
|
||||||
|
profile: ProfileResponse
|
||||||
|
revised: bool = False
|
||||||
|
revision_note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReflectionThreadResponse(BaseModel):
|
||||||
|
messages: list[ReflectionMessageOut]
|
||||||
|
profile: ProfileResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileRevisionOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
source: str # "extraction" | "reflection" | "manual_edit"
|
||||||
|
fields: dict
|
||||||
|
note: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ConversationResponse(BaseModel):
|
class ConversationResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""ReflectionCoach: the Phase 2 AI-coach reflection loop.
|
||||||
|
|
||||||
|
The coach is a MIRROR, never a compass. It reflects the person's own profile
|
||||||
|
back to them in plain language, listens to their reactions, and — only when
|
||||||
|
they explicitly correct or add something — proposes revised text for the
|
||||||
|
affected prose fields using the person's own direction. It never prescribes
|
||||||
|
goals or invents direction.
|
||||||
|
|
||||||
|
Like DiscoveryExtractor, this class is responsible only for plumbing: building
|
||||||
|
the messages, calling the model, and parsing/validating the JSON it returns.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||||
|
MAX_TOKENS = 1200
|
||||||
|
|
||||||
|
# The only profile fields the coach may propose changes to. The structural
|
||||||
|
# Enneagram read (triad/type/wing/variant) is never editable via reflection.
|
||||||
|
EDITABLE_FIELDS = (
|
||||||
|
"love_summary",
|
||||||
|
"strength_summary",
|
||||||
|
"mission_summary",
|
||||||
|
"vocation_summary",
|
||||||
|
"overlap_narrative",
|
||||||
|
"short_term_goals",
|
||||||
|
"long_term_goals",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Maps a stored ReflectionMessage.role to an Anthropic message role.
|
||||||
|
ROLE_TO_API = {"coach": "assistant", "person": "user"}
|
||||||
|
|
||||||
|
# Sent as the first (user) turn on every call so the conversation always
|
||||||
|
# starts with a user message, and to frame the coach's task. Not stored.
|
||||||
|
PRIMER = (
|
||||||
|
"I have just completed my self-discovery profile (it is in your "
|
||||||
|
"instructions). Reflect it back to me so I can see whether it fits."
|
||||||
|
)
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are an AI coach inside a self-discovery tool. You are a MIRROR, never a compass.
|
||||||
|
|
||||||
|
THE PERSON'S CURRENT PROFILE:
|
||||||
|
{profile}
|
||||||
|
|
||||||
|
YOUR ROLE:
|
||||||
|
- Reflect this profile back in warm, plain language and ask whether it lands: in spirit, "Here is what I am hearing — do you recognize yourself? What would you add, change, or disagree with?"
|
||||||
|
- Listen to how the person reacts. When they correct, add to, or push back on something, reflect their own words back to them — clarify and sharpen what THEY mean.
|
||||||
|
- Ask gentle, open questions that help the person articulate their own sense of direction.
|
||||||
|
|
||||||
|
ABSOLUTE RULES (mirror, not compass):
|
||||||
|
- NEVER prescribe goals, paths, careers, or what they "should" do.
|
||||||
|
- NEVER invent a direction the person did not express. If you are unsure what they mean, ask rather than assume.
|
||||||
|
- Do NOT mention Enneagram type numbers; describe patterns in plain language.
|
||||||
|
- Keep replies short and conversational — 2 to 5 sentences, at most one question.
|
||||||
|
|
||||||
|
PROPOSING REVISIONS:
|
||||||
|
- Only when the person explicitly corrects, adds to, or asks to change part of their profile, propose updated text for the affected field(s), written in their own direction. Editable fields: love_summary, strength_summary, mission_summary, vocation_summary, overlap_narrative, short_term_goals, long_term_goals.
|
||||||
|
- Otherwise set "revisions" to null. Never change their Enneagram type, triad, or instinctual variant. Never revise just because you could — only to capture what the person said.
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
Respond ONLY with valid JSON. No preamble, no markdown fences.
|
||||||
|
|
||||||
|
{{
|
||||||
|
"message": "your reflective reply to the person, in second person (you/your), warm and plain",
|
||||||
|
"revisions": {{ "<field>": "<revised text in the person's own direction>" }} or null,
|
||||||
|
"revision_note": "a short phrase naming what changed, or null"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
RETRY_REMINDER = (
|
||||||
|
"Your previous response could not be parsed as JSON. Respond ONLY with the "
|
||||||
|
"single valid JSON object described in your instructions — no preamble, no "
|
||||||
|
"explanation, and no markdown code fences."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReflectionError(Exception):
|
||||||
|
"""Raised when a reflection turn fails (API error or unparseable output)."""
|
||||||
|
|
||||||
|
|
||||||
|
class ReflectionCoach:
|
||||||
|
"""Generates one coach turn given the profile and the dialogue so far."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
|
||||||
|
if not api_key:
|
||||||
|
raise ReflectionError(
|
||||||
|
"ANTHROPIC_API_KEY is not set; cannot run reflection."
|
||||||
|
)
|
||||||
|
self.model = model
|
||||||
|
self.client = AsyncAnthropic(api_key=api_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def profile_context(profile: Dict[str, Any]) -> str:
|
||||||
|
"""Render the current profile as plain text for the system prompt."""
|
||||||
|
triad = {
|
||||||
|
"gut": "leads with instinct and will (gut-centered)",
|
||||||
|
"heart": "leads with feeling and connection (heart-centered)",
|
||||||
|
"head": "leads with thought and perception (head-centered)",
|
||||||
|
}.get(profile.get("triad") or "", "centered pattern not yet clear")
|
||||||
|
lines = [
|
||||||
|
f"- Core pattern: {triad}",
|
||||||
|
f"- What you love: {profile.get('love_summary') or '(none)'}",
|
||||||
|
f"- What you are good at: {profile.get('strength_summary') or '(none)'}",
|
||||||
|
f"- What the world needs from you: {profile.get('mission_summary') or '(none)'}",
|
||||||
|
f"- What you can be paid for: {profile.get('vocation_summary') or '(none)'}",
|
||||||
|
f"- Where it converges: {profile.get('overlap_narrative') or '(none)'}",
|
||||||
|
f"- Near-term goals (6-12mo): {profile.get('short_term_goals') or '(none stated)'}",
|
||||||
|
f"- Long-term goals (3-5yr): {profile.get('long_term_goals') or '(none stated)'}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _build_messages(
|
||||||
|
self, history: List[Dict[str, str]]
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Build the Anthropic messages array: a fixed user primer followed by
|
||||||
|
the stored turns mapped to user/assistant roles."""
|
||||||
|
messages = [{"role": "user", "content": PRIMER}]
|
||||||
|
for turn in history:
|
||||||
|
api_role = ROLE_TO_API.get(turn["role"])
|
||||||
|
if api_role is None:
|
||||||
|
continue
|
||||||
|
messages.append({"role": api_role, "content": turn["content"]})
|
||||||
|
return messages
|
||||||
|
|
||||||
|
async def _call_model(
|
||||||
|
self, profile: Dict[str, Any], messages: List[Dict[str, str]]
|
||||||
|
) -> str:
|
||||||
|
response = await self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=MAX_TOKENS,
|
||||||
|
system=SYSTEM_PROMPT.format(
|
||||||
|
profile=self.profile_context(profile)
|
||||||
|
),
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
return response.content[0].text
|
||||||
|
|
||||||
|
async def reflect(
|
||||||
|
self, profile: Dict[str, Any], history: List[Dict[str, str]]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Produce one coach turn.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: the current profile dict (at least the editable fields and
|
||||||
|
triad).
|
||||||
|
history: prior turns as ``[{"role": "coach"|"person", "content": ...}]``
|
||||||
|
in order. Empty for the opening reflection. The last turn, if
|
||||||
|
any, should be the person's latest message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"message": str, "revisions": dict|None, "revision_note": str|None}``
|
||||||
|
with revisions filtered to the editable fields only.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReflectionError on API failure or repeated parse failure.
|
||||||
|
"""
|
||||||
|
messages = self._build_messages(history)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await self._call_model(profile, messages)
|
||||||
|
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
|
||||||
|
raise ReflectionError(f"Anthropic API call failed: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._parse(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
retry = messages + [
|
||||||
|
{"role": "assistant", "content": raw},
|
||||||
|
{"role": "user", "content": RETRY_REMINDER},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
raw_retry = await self._call_model(profile, retry)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ReflectionError(
|
||||||
|
f"Anthropic API call failed on retry: {exc}"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
return self._parse(raw_retry)
|
||||||
|
except (json.JSONDecodeError, ValueError) as exc:
|
||||||
|
raise ReflectionError(
|
||||||
|
f"Model did not return valid JSON after retry: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_fences(text: str) -> str:
|
||||||
|
stripped = (text or "").strip()
|
||||||
|
if stripped.startswith("```"):
|
||||||
|
newline = stripped.find("\n")
|
||||||
|
if newline != -1:
|
||||||
|
stripped = stripped[newline + 1 :]
|
||||||
|
if stripped.rstrip().endswith("```"):
|
||||||
|
stripped = stripped.rstrip()[: -len("```")]
|
||||||
|
return stripped.strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, raw: str) -> Dict[str, Any]:
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
raise ValueError("empty response from model")
|
||||||
|
|
||||||
|
data = json.loads(cls._strip_fences(raw))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("top-level JSON value is not an object")
|
||||||
|
|
||||||
|
message = data.get("message")
|
||||||
|
if not isinstance(message, str) or not message.strip():
|
||||||
|
raise ValueError("missing or empty 'message'")
|
||||||
|
|
||||||
|
revisions = cls._clean_revisions(data.get("revisions"))
|
||||||
|
note = data.get("revision_note")
|
||||||
|
if not isinstance(note, str) or not note.strip():
|
||||||
|
note = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": message.strip(),
|
||||||
|
"revisions": revisions,
|
||||||
|
"revision_note": note,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_revisions(revisions: Any) -> Optional[Dict[str, str]]:
|
||||||
|
"""Keep only editable string fields; drop anything else (e.g. an
|
||||||
|
attempt to change triad/type). Returns None if nothing valid remains."""
|
||||||
|
if not isinstance(revisions, dict):
|
||||||
|
return None
|
||||||
|
cleaned = {
|
||||||
|
k: v
|
||||||
|
for k, v in revisions.items()
|
||||||
|
if k in EDITABLE_FIELDS and isinstance(v, str) and v.strip()
|
||||||
|
}
|
||||||
|
return cleaned or None
|
||||||
@@ -137,7 +137,10 @@
|
|||||||
: `<div class="nav">
|
: `<div class="nav">
|
||||||
<button class="btn-ghost" id="editBtn">Edit my words</button>
|
<button class="btn-ghost" id="editBtn">Edit my words</button>
|
||||||
<button class="btn-primary" id="confirmBtn">This is me</button>
|
<button class="btn-primary" id="confirmBtn">This is me</button>
|
||||||
</div>`;
|
</div>
|
||||||
|
<p class="back-link" style="text-align:center;margin-top:18px">
|
||||||
|
<a href="/static/reflect.html">Not quite right? Talk it through with your coach →</a>
|
||||||
|
</p>`;
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="profile-narrative">${escapeHtml(
|
<div class="profile-narrative">${escapeHtml(
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ImpactFlow — Reflect With Your Coach</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<script src="/static/auth.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="brand">ImpactFlow · Reflect With Your Coach</div>
|
||||||
|
<div id="content"><p>Loading…</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const content = document.getElementById("content");
|
||||||
|
let profile = null;
|
||||||
|
let locked = false;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The coach mirrors the person's own direction; this panel shows the
|
||||||
|
// current profile prose so the person can watch it sharpen as they talk.
|
||||||
|
function profileSummary() {
|
||||||
|
if (!profile) return "";
|
||||||
|
const goals = [
|
||||||
|
["Near-term (6–12 months)", profile.short_term_goals],
|
||||||
|
["Long-term (3–5 years)", profile.long_term_goals],
|
||||||
|
].filter(([, v]) => v && v.trim());
|
||||||
|
const goalRows = goals
|
||||||
|
.map(([t, v]) => `<p><strong>${t}:</strong> ${escapeHtml(v)}</p>`)
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<div class="triad-block" id="summary">
|
||||||
|
<h2>Your profile, in your words</h2>
|
||||||
|
<p>${escapeHtml(profile.overlap_narrative)}</p>
|
||||||
|
${goalRows}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubble(role, text) {
|
||||||
|
const who = role === "coach" ? "Coach" : "You";
|
||||||
|
return `<div class="bubble ${role}"><span class="who">${who}</span>${escapeHtml(
|
||||||
|
text
|
||||||
|
)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(messages) {
|
||||||
|
const thread = messages.map((m) => bubble(m.role, m.content)).join("");
|
||||||
|
content.innerHTML = `
|
||||||
|
${profileSummary()}
|
||||||
|
<p class="section-label">Reflection</p>
|
||||||
|
<div class="thread" id="thread">${thread}</div>
|
||||||
|
${
|
||||||
|
locked
|
||||||
|
? `<div class="confirm-row"><button class="btn-primary confirmed" disabled>Affirmed — this is you ✓</button></div>`
|
||||||
|
: `<div class="reflect-input">
|
||||||
|
<textarea id="msg" class="edit" placeholder="Tell your coach what fits, what doesn't, what you'd change…"></textarea>
|
||||||
|
<div class="nav">
|
||||||
|
<button class="btn-ghost" id="affirmBtn">This is me — affirm</button>
|
||||||
|
<button class="btn-primary" id="sendBtn">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
|
||||||
|
`;
|
||||||
|
scrollThread();
|
||||||
|
if (!locked) {
|
||||||
|
document.getElementById("sendBtn").addEventListener("click", send);
|
||||||
|
document.getElementById("affirmBtn").addEventListener("click", affirm);
|
||||||
|
const ta = document.getElementById("msg");
|
||||||
|
ta.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) send();
|
||||||
|
});
|
||||||
|
ta.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollThread() {
|
||||||
|
const t = document.getElementById("thread");
|
||||||
|
if (t) t.scrollTop = t.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendBubble(role, text) {
|
||||||
|
const t = document.getElementById("thread");
|
||||||
|
if (t) {
|
||||||
|
t.insertAdjacentHTML("beforeend", bubble(role, text));
|
||||||
|
scrollThread();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = [];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await authedFetch("/discovery/profile/me/reflection");
|
||||||
|
if (!res.ok) throw new Error("Could not load your reflection");
|
||||||
|
const data = await res.json();
|
||||||
|
profile = data.profile;
|
||||||
|
locked = !!profile.locked;
|
||||||
|
messages = data.messages || [];
|
||||||
|
} catch (err) {
|
||||||
|
content.innerHTML = `<div class="error-box">${escapeHtml(
|
||||||
|
err.message
|
||||||
|
)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
render(messages);
|
||||||
|
if (!locked && messages.length === 0) {
|
||||||
|
// Kick off the coach's opening reflection.
|
||||||
|
await turn("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One reflection turn. Empty text = opener.
|
||||||
|
async function turn(text) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
const sendBtn = document.getElementById("sendBtn");
|
||||||
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
|
if (text) appendBubble("person", text);
|
||||||
|
appendBubble("coach", "…");
|
||||||
|
try {
|
||||||
|
const res = await authedFetch("/discovery/profile/me/reflect", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: text }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({ detail: "Reflection failed" }));
|
||||||
|
throw new Error(d.detail || "Reflection failed");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
profile = data.profile; // may carry applied revisions
|
||||||
|
// Replace the "…" placeholder with the real coach reply.
|
||||||
|
const t = document.getElementById("thread");
|
||||||
|
t.lastElementChild.remove();
|
||||||
|
appendBubble("coach", data.message.content);
|
||||||
|
if (data.revised) {
|
||||||
|
refreshSummary();
|
||||||
|
appendNote(
|
||||||
|
data.revision_note
|
||||||
|
? `Updated your profile: ${data.revision_note}`
|
||||||
|
: "Updated your profile to match what you said."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const t = document.getElementById("thread");
|
||||||
|
if (t && t.lastElementChild) t.lastElementChild.remove();
|
||||||
|
appendNote(err.message, true);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
const b = document.getElementById("sendBtn");
|
||||||
|
if (b) b.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSummary() {
|
||||||
|
const old = document.getElementById("summary");
|
||||||
|
if (old) old.outerHTML = profileSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendNote(text, isError) {
|
||||||
|
const t = document.getElementById("thread");
|
||||||
|
if (t)
|
||||||
|
t.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
`<div class="note ${isError ? "err" : ""}">${escapeHtml(text)}</div>`
|
||||||
|
);
|
||||||
|
scrollThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const ta = document.getElementById("msg");
|
||||||
|
const text = ta.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
ta.value = "";
|
||||||
|
await turn(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function affirm() {
|
||||||
|
const btn = document.getElementById("affirmBtn");
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await authedFetch("/discovery/profile/me/confirm", {
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Could not affirm");
|
||||||
|
locked = true;
|
||||||
|
render(messages.length ? messages : []);
|
||||||
|
// Re-render reads from the live thread, so just lock the controls.
|
||||||
|
location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
btn.disabled = false;
|
||||||
|
appendNote(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -302,6 +302,86 @@ textarea.edit {
|
|||||||
font-size: 0.98rem;
|
font-size: 0.98rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Reflection chat ---------- */
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-height: 55vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 2px 8px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble .who {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.coach {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(13, 27, 42, 0.1);
|
||||||
|
color: var(--navy-soft);
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.person {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--navy);
|
||||||
|
color: var(--cream);
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.person .who {
|
||||||
|
color: var(--gold);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
align-self: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gold);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note.err {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reflect-input textarea.edit {
|
||||||
|
min-height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a {
|
||||||
|
color: var(--navy-soft);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a:hover {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
.error-box {
|
.error-box {
|
||||||
background: #fdecea;
|
background: #fdecea;
|
||||||
border: 1px solid var(--red);
|
border: 1px solid var(--red);
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Tests for the Phase 2 reflection endpoints.
|
||||||
|
|
||||||
|
The Anthropic-backed ReflectionCoach is replaced with a FakeCoach (via
|
||||||
|
monkeypatch on the router) so these run offline and deterministically.
|
||||||
|
Auth uses the X-API-Key admin identity.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
API_KEY = {"X-API-Key": "test-api-key"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_profile(locked: bool = False) -> str:
|
||||||
|
from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models import DiscoveryConversation, DiscoveryProfile
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await ensure_api_key_admin(db)
|
||||||
|
conv = DiscoveryConversation(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=API_KEY_ADMIN_ID,
|
||||||
|
started_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(conv)
|
||||||
|
await db.commit()
|
||||||
|
profile = DiscoveryProfile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=API_KEY_ADMIN_ID,
|
||||||
|
conversation_id=conv.id,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
triad="gut",
|
||||||
|
probable_type=8,
|
||||||
|
wing=9,
|
||||||
|
short_term_goals="orig short",
|
||||||
|
long_term_goals="orig long",
|
||||||
|
overlap_narrative="orig narrative",
|
||||||
|
locked=locked,
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.commit()
|
||||||
|
return profile.id
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCoach:
|
||||||
|
"""Stand-in for ReflectionCoach. Returns a configurable response and
|
||||||
|
records the (profile, history) it was called with."""
|
||||||
|
|
||||||
|
response = {"message": "Here is what I am hearing — does it fit?",
|
||||||
|
"revisions": None, "revision_note": None}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def __init__(self, api_key=None, model=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def reflect(self, profile, history):
|
||||||
|
FakeCoach.calls.append({"profile": profile, "history": list(history)})
|
||||||
|
return dict(FakeCoach.response)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def fake_coach(monkeypatch, app_client):
|
||||||
|
# Depends on app_client so it patches the *reimported* router module
|
||||||
|
# (the app_client fixture reloads app.* modules before this runs).
|
||||||
|
FakeCoach.calls = []
|
||||||
|
FakeCoach.response = {
|
||||||
|
"message": "Here is what I am hearing — does it fit?",
|
||||||
|
"revisions": None,
|
||||||
|
"revision_note": None,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("app.routers.discovery.ReflectionCoach", FakeCoach)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def test_opening_reflection(app_client):
|
||||||
|
await _seed_profile()
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect", headers=API_KEY, json={}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["message"]["role"] == "coach"
|
||||||
|
assert body["revised"] is False
|
||||||
|
# Opener is called with no history.
|
||||||
|
assert FakeCoach.calls[-1]["history"] == []
|
||||||
|
|
||||||
|
# The thread now has exactly one coach message.
|
||||||
|
thread = (await app_client.get(
|
||||||
|
"/discovery/profile/me/reflection", headers=API_KEY
|
||||||
|
)).json()
|
||||||
|
assert len(thread["messages"]) == 1
|
||||||
|
assert thread["messages"][0]["role"] == "coach"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_person_turn_records_both_messages(app_client):
|
||||||
|
await _seed_profile()
|
||||||
|
await app_client.post("/discovery/profile/me/reflect", headers=API_KEY, json={})
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect",
|
||||||
|
headers=API_KEY,
|
||||||
|
json={"message": "Mostly right, thanks."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
thread = (await app_client.get(
|
||||||
|
"/discovery/profile/me/reflection", headers=API_KEY
|
||||||
|
)).json()
|
||||||
|
roles = [m["role"] for m in thread["messages"]]
|
||||||
|
assert roles == ["coach", "person", "coach"]
|
||||||
|
# The coach saw the person's turn in history.
|
||||||
|
assert FakeCoach.calls[-1]["history"][-1] == {
|
||||||
|
"role": "person", "content": "Mostly right, thanks."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reflection_applies_revision_and_records_history(app_client):
|
||||||
|
await _seed_profile()
|
||||||
|
FakeCoach.response = {
|
||||||
|
"message": "Updated your near-term goal.",
|
||||||
|
"revisions": {"short_term_goals": "Launch the pilot this fall."},
|
||||||
|
"revision_note": "near-term goal updated",
|
||||||
|
}
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect",
|
||||||
|
headers=API_KEY,
|
||||||
|
json={"message": "Change my short-term goal."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["revised"] is True
|
||||||
|
assert body["profile"]["short_term_goals"] == "Launch the pilot this fall."
|
||||||
|
assert body["revision_note"] == "near-term goal updated"
|
||||||
|
|
||||||
|
# Persisted on the profile.
|
||||||
|
prof = (await app_client.get(
|
||||||
|
"/discovery/profile/me", headers=API_KEY
|
||||||
|
)).json()
|
||||||
|
assert prof["short_term_goals"] == "Launch the pilot this fall."
|
||||||
|
|
||||||
|
# Recorded in the revision history with source 'reflection'.
|
||||||
|
revs = (await app_client.get(
|
||||||
|
"/discovery/profile/me/revisions", headers=API_KEY
|
||||||
|
)).json()
|
||||||
|
assert any(rev["source"] == "reflection" for rev in revs)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_endpoint_ignores_noneditable_revision_fields(app_client):
|
||||||
|
await _seed_profile()
|
||||||
|
FakeCoach.response = {
|
||||||
|
"message": "ok",
|
||||||
|
"revisions": {"triad": "head", "long_term_goals": "Statewide network."},
|
||||||
|
"revision_note": "x",
|
||||||
|
}
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect",
|
||||||
|
headers=API_KEY,
|
||||||
|
json={"message": "tweak"},
|
||||||
|
)
|
||||||
|
body = r.json()
|
||||||
|
assert body["profile"]["long_term_goals"] == "Statewide network."
|
||||||
|
assert body["profile"]["triad"] == "gut" # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reflect_on_locked_profile_returns_409(app_client):
|
||||||
|
await _seed_profile(locked=True)
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect", headers=API_KEY, json={"message": "hi"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_message_after_opener_returns_400(app_client):
|
||||||
|
await _seed_profile()
|
||||||
|
await app_client.post("/discovery/profile/me/reflect", headers=API_KEY, json={})
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect", headers=API_KEY, json={}
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reflect_requires_auth(app_client):
|
||||||
|
r = await app_client.post("/discovery/profile/me/reflect", json={})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reflection_without_profile_404(app_client):
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect", headers=API_KEY, json={}
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Unit tests for ReflectionCoach (the Phase 2 AI-coach mirror).
|
||||||
|
|
||||||
|
The Anthropic client is faked so the suite is deterministic and offline.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.reflector import ReflectionCoach, ReflectionError
|
||||||
|
|
||||||
|
PROFILE = {
|
||||||
|
"triad": "gut",
|
||||||
|
"love_summary": "You love building things with your hands.",
|
||||||
|
"strength_summary": "You see what needs doing and move.",
|
||||||
|
"mission_summary": "Protect people without power.",
|
||||||
|
"vocation_summary": "Lead and build under pressure.",
|
||||||
|
"overlap_narrative": "You come alive protecting others.",
|
||||||
|
"short_term_goals": "Run a pilot welding cohort.",
|
||||||
|
"long_term_goals": "A statewide trades outfit.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMessages:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self._responses = list(responses)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def create(self, **kwargs):
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
text = self._responses.pop(0)
|
||||||
|
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self.messages = FakeMessages(responses)
|
||||||
|
|
||||||
|
|
||||||
|
def make_coach(responses) -> ReflectionCoach:
|
||||||
|
coach = ReflectionCoach(api_key="test-key")
|
||||||
|
coach.client = FakeClient(responses)
|
||||||
|
return coach
|
||||||
|
|
||||||
|
|
||||||
|
def reply(message, revisions=None, note=None) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{"message": message, "revisions": revisions, "revision_note": note}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_opening_reflection_no_history():
|
||||||
|
coach = make_coach([reply("Here is what I am hearing — does it fit?")])
|
||||||
|
result = await coach.reflect(PROFILE, [])
|
||||||
|
|
||||||
|
assert result["message"].startswith("Here is what I am hearing")
|
||||||
|
assert result["revisions"] is None
|
||||||
|
# The API call starts with the user primer (Anthropic requires user-first).
|
||||||
|
msgs = coach.client.messages.calls[0]["messages"]
|
||||||
|
assert msgs[0]["role"] == "user"
|
||||||
|
assert len(msgs) == 1
|
||||||
|
# The profile context is injected into the system prompt.
|
||||||
|
assert "welding" in coach.client.messages.calls[0]["system"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_maps_roles_and_appends_primer():
|
||||||
|
coach = make_coach([reply("Got it.")])
|
||||||
|
history = [
|
||||||
|
{"role": "coach", "content": "Does this fit?"},
|
||||||
|
{"role": "person", "content": "Mostly, but change my goal."},
|
||||||
|
]
|
||||||
|
await coach.reflect(PROFILE, history)
|
||||||
|
|
||||||
|
msgs = coach.client.messages.calls[0]["messages"]
|
||||||
|
assert [m["role"] for m in msgs] == ["user", "assistant", "user"]
|
||||||
|
assert msgs[-1]["content"] == "Mostly, but change my goal."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revisions_filtered_to_editable_fields():
|
||||||
|
"""The coach must never change structural fields like triad/type."""
|
||||||
|
coach = make_coach([
|
||||||
|
reply(
|
||||||
|
"Updating that.",
|
||||||
|
revisions={"short_term_goals": "New near-term goal.", "triad": "head"},
|
||||||
|
note="goal updated",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
result = await coach.reflect(PROFILE, [])
|
||||||
|
|
||||||
|
assert result["revisions"] == {"short_term_goals": "New near-term goal."}
|
||||||
|
assert "triad" not in result["revisions"]
|
||||||
|
assert result["revision_note"] == "goal updated"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_revisions_become_none():
|
||||||
|
coach = make_coach([reply("Just reflecting.", revisions={})])
|
||||||
|
result = await coach.reflect(PROFILE, [])
|
||||||
|
assert result["revisions"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bad_json_triggers_one_retry():
|
||||||
|
coach = make_coach(["not json at all", reply("Recovered.")])
|
||||||
|
result = await coach.reflect(PROFILE, [])
|
||||||
|
|
||||||
|
assert result["message"] == "Recovered."
|
||||||
|
assert len(coach.client.messages.calls) == 2
|
||||||
|
# Retry carries the JSON-only reminder.
|
||||||
|
assert "JSON" in coach.client.messages.calls[1]["messages"][-1]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_message_after_retry_raises():
|
||||||
|
coach = make_coach([json.dumps({"revisions": None}), json.dumps({"revisions": None})])
|
||||||
|
with pytest.raises(ReflectionError):
|
||||||
|
await coach.reflect(PROFILE, [])
|
||||||
|
assert len(coach.client.messages.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_api_key_raises():
|
||||||
|
with pytest.raises(ReflectionError):
|
||||||
|
ReflectionCoach(api_key="")
|
||||||
@@ -33,6 +33,21 @@ def test_profile_page_has_edit_affordance():
|
|||||||
assert "Edit my words" in html
|
assert "Edit my words" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflect_page_uses_reflection_endpoints():
|
||||||
|
"""Phase 2 reflection page drives the coach loop via authedFetch."""
|
||||||
|
html = Path("app/static/reflect.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "/discovery/profile/me/reflect" in html
|
||||||
|
assert "/discovery/profile/me/reflection" in html
|
||||||
|
assert "authedFetch" in html
|
||||||
|
assert "user_id" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_page_links_to_reflection():
|
||||||
|
html = Path("app/static/profile.html").read_text(encoding="utf-8")
|
||||||
|
assert "/static/reflect.html" in html
|
||||||
|
|
||||||
|
|
||||||
def test_auth_helper_sends_credentials_and_refreshes():
|
def test_auth_helper_sends_credentials_and_refreshes():
|
||||||
js = Path("app/static/auth.js").read_text(encoding="utf-8")
|
js = Path("app/static/auth.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user