Files
Joel Salmon b4d8d17aed 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>
2026-06-16 20:58:45 -05:00

127 lines
4.1 KiB
Python

"""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="")