diff --git a/README.md b/README.md index 9badaad..f798873 100644 --- a/README.md +++ b/README.md @@ -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 `PATCH /discovery/profile/me`, then lock it with `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 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/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/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/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/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/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/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_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_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 | @@ -171,9 +180,11 @@ every subsequent user defaults to `role=user`. Admin-only routes (e.g. 1. In 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:8011/api/auth/callback` for local dev - - `http://:8011/api/auth/callback` for the deployed instance +3. Add authorized redirect URIs that match `OAUTH_REDIRECT_URI` in `.env` + exactly (scheme, host, port, path — Google treats `localhost` and + `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 `GOOGLE_CLIENT_SECRET`. 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 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 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 | | `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 | +| `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) | ## Data Model @@ -477,6 +512,35 @@ Stores one extracted profile for one conversation. | `confidence_json` | text nullable | JSON string for confidence flags | | `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 `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 - "This is me" locks the profile with `PUT /discovery/profile/me/confirm`; a 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 @@ -588,6 +663,26 @@ Environment variables: `app/database.py` creates the SQLite directory automatically when the URL uses 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 This path requires Docker Desktop or another Docker Engine installation with diff --git a/alembic/versions/004_add_reflection.py b/alembic/versions/004_add_reflection.py new file mode 100644 index 0000000..5c3135a --- /dev/null +++ b/alembic/versions/004_add_reflection.py @@ -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") diff --git a/app/models.py b/app/models.py index 0f0c8d5..6ab2997 100644 --- a/app/models.py +++ b/app/models.py @@ -146,3 +146,45 @@ class DiscoveryProfile(Base): locked: Mapped[bool] = mapped_column( 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) diff --git a/app/routers/discovery.py b/app/routers/discovery.py index 0ac87ba..8bbffbe 100644 --- a/app/routers/discovery.py +++ b/app/routers/discovery.py @@ -17,8 +17,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.auth import get_current_user 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.reflector import ( + EDITABLE_FIELDS, + ReflectionCoach, + ReflectionError, +) router = APIRouter(prefix="/discovery", tags=["discovery"]) @@ -27,6 +38,33 @@ def _now() -> datetime: 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( profile: DiscoveryProfile, extraction_notes: str | None = None ) -> schemas.ProfileResponse: @@ -182,6 +220,7 @@ async def complete_conversation( ) conversation.completed_at = _now() db.add(profile) + _record_revision(db, profile, source="extraction") await db.commit() return _to_profile_response( @@ -223,6 +262,7 @@ async def update_my_profile( raise HTTPException(status_code=400, detail="No fields to update") for field, value in updates.items(): setattr(profile, field, value) + _record_revision(db, profile, source="manual_edit", note="manual edit") await db.commit() await db.refresh(profile) @@ -244,6 +284,181 @@ async def confirm_my_profile( 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( "/conversation/{conversation_id}", response_model=schemas.ConversationResponse, diff --git a/app/schemas.py b/app/schemas.py index c9645f9..850c7e9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -71,6 +71,43 @@ class ConfirmResponse(BaseModel): 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): id: str user_id: str diff --git a/app/services/reflector.py b/app/services/reflector.py new file mode 100644 index 0000000..f4f8639 --- /dev/null +++ b/app/services/reflector.py @@ -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": {{ "": "" }} 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 diff --git a/app/static/profile.html b/app/static/profile.html index 15b2d3d..b5e75f8 100644 --- a/app/static/profile.html +++ b/app/static/profile.html @@ -137,7 +137,10 @@ : ``; + + `; content.innerHTML = `
${escapeHtml( diff --git a/app/static/reflect.html b/app/static/reflect.html new file mode 100644 index 0000000..84c1b0c --- /dev/null +++ b/app/static/reflect.html @@ -0,0 +1,216 @@ + + + + + + ImpactFlow — Reflect With Your Coach + + + + + + + +
+
ImpactFlow · Reflect With Your Coach
+

Loading…

+
+ + + + diff --git a/app/static/style.css b/app/static/style.css index 0f38020..67fb016 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -302,6 +302,86 @@ textarea.edit { 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 { background: #fdecea; border: 1px solid var(--red); diff --git a/tests/test_reflection.py b/tests/test_reflection.py new file mode 100644 index 0000000..dc38bd7 --- /dev/null +++ b/tests/test_reflection.py @@ -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 diff --git a/tests/test_reflector.py b/tests/test_reflector.py new file mode 100644 index 0000000..dc01dbe --- /dev/null +++ b/tests/test_reflector.py @@ -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="") diff --git a/tests/test_static_discovery.py b/tests/test_static_discovery.py index ed2fc11..0c263a7 100644 --- a/tests/test_static_discovery.py +++ b/tests/test_static_discovery.py @@ -33,6 +33,21 @@ def test_profile_page_has_edit_affordance(): 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(): js = Path("app/static/auth.js").read_text(encoding="utf-8")