mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:00: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:
@@ -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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user