Files
impactflow_discovery/tests/test_reflection.py
Joel Salmon b9d7b0e22b Phase 5: iteration & polish (visualizations, goal history, smart tagging)
Final roadmap phase. No DB migration — it reads data already captured.

- Goal-evolution history: GET /discovery/profile/me/goal-history derives a
  per-goal timeline from the profile_revision snapshots (pure aggregator in
  app/services/profile_history.py).
- Smart tagging: POST /discovery/integration/suggest-foundation suggests which
  foundation a task builds toward + rationale/confidence (FoundationTagger,
  app/services/tagging.py). Suggestion only; the person confirms by posting the
  task mapping.
- Deeper goal-refinement: the reflect loop accepts an optional focus ("goals")
  that steers the coach toward sharpening goals — still mirror, not compass.
- Visualizations: visuals.html renders an Ikigai Venn and an Enneagram diagram
  (plain-language callouts, not the raw type number) plus the goal-evolution
  timeline; linked from profile.html.

Tests: 99 passing (added pure goal-history tests, goal-history + suggest
endpoint tests, reflect-focus passthrough; run in-container). README updated.

This completes the ImpactFlow Vision roadmap (Phases 1-5) on the Discovery side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:37:33 -05:00

206 lines
6.7 KiB
Python

"""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, focus=""):
FakeCoach.calls.append(
{"profile": profile, "history": list(history), "focus": focus}
)
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_passes_focus_to_coach(app_client):
"""Phase 5: a goal-refinement focus reaches the coach."""
await _seed_profile()
await app_client.post(
"/discovery/profile/me/reflect",
headers=API_KEY,
json={"message": "let's sharpen my goals", "focus": "goals"},
)
assert FakeCoach.calls[-1]["focus"] == "goals"
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