mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:30:35 +00:00
b9d7b0e22b
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>
206 lines
6.7 KiB
Python
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
|