mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 06:00:35 +00:00
33674f92f4
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with the auth layer. Goals (5 -> 7 prompts): - Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw text on the conversation and store AI-articulated goal summaries on the profile. Extractor articulates the person's own stated goals (mirror, not compass) and never fabricates. Alembic 003 adds the four columns. Cookie-based browser sessions (fixes frontend<->auth desync): - OAuth callback now sets httpOnly session cookies and redirects into the app instead of returning JSON. get_current_user gains a cookie fallback (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie and set/clear cookies. New shared auth.js (authedFetch) sends cookies and silently refreshes on 401. Static pages drop the bogus user_id and call the correct /me endpoints. Profile editing (read/edit/affirm): - PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap narrative, goals); owner-scoped, partial update, 409 when locked. Edit mode in profile.html with Save/Cancel. Also: bump default model to claude-sonnet-4-6, align ports to 8011 (OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and refresh the README to match the shipped behavior. Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases; factored a shared app_client fixture into conftest.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
7.4 KiB
Python
214 lines
7.4 KiB
Python
"""Tests for DiscoveryExtractor.
|
||
|
||
The Anthropic API is mocked: a FakeClient returns canned JSON so the suite is
|
||
deterministic and runs without a network call or API key. The fixtures supply
|
||
realistic narrative input, so these tests exercise the full extract() pipeline
|
||
(message building, parsing, validation, and retry) end to end.
|
||
"""
|
||
import json
|
||
import os
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from app.services.extractor import (
|
||
DiscoveryExtractionError,
|
||
DiscoveryExtractor,
|
||
)
|
||
|
||
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||
|
||
|
||
def load_fixture(name: str) -> dict:
|
||
with open(os.path.join(FIXTURE_DIR, name), encoding="utf-8") as fh:
|
||
return json.load(fh)
|
||
|
||
|
||
class FakeMessages:
|
||
"""Stand-in for client.messages that returns queued responses."""
|
||
|
||
def __init__(self, responses):
|
||
self._responses = list(responses)
|
||
self.calls = []
|
||
|
||
async def create(self, **kwargs):
|
||
self.calls.append(kwargs)
|
||
if not self._responses:
|
||
raise AssertionError("messages.create called more times than queued")
|
||
text = self._responses.pop(0)
|
||
return SimpleNamespace(content=[SimpleNamespace(text=text)])
|
||
|
||
|
||
class FakeClient:
|
||
def __init__(self, responses):
|
||
self.messages = FakeMessages(responses)
|
||
|
||
|
||
def make_profile_json(
|
||
triad: str,
|
||
probable_type: int,
|
||
wing: int,
|
||
variant: str = "sp",
|
||
stack: str = "sp/so/sx",
|
||
) -> str:
|
||
"""Build a well-formed profile JSON string for the given archetype."""
|
||
return json.dumps(
|
||
{
|
||
"triad": triad,
|
||
"probable_type": probable_type,
|
||
"wing": wing,
|
||
"instinctual_variant": variant,
|
||
"instinctual_stack": stack,
|
||
"love_summary": "You light up around hands-on, purposeful work.",
|
||
"strength_summary": "You see the whole picture and act decisively.",
|
||
"mission_summary": "People around you need protection and clarity.",
|
||
"vocation_summary": "You can be paid to lead and build under pressure.",
|
||
"overlap_narrative": (
|
||
"You come most alive where your instinct to act, your eye for "
|
||
"what's broken, and the world's need for someone steady all "
|
||
"meet. The work that fits you lets you move first and bring "
|
||
"others with you."
|
||
),
|
||
"short_term_goals": (
|
||
"Over the next year you want to get the training program off "
|
||
"the ground and prove it works with a first cohort."
|
||
),
|
||
"long_term_goals": (
|
||
"Within five years you see this grown into something "
|
||
"statewide that makes people strong enough to never need "
|
||
"rescuing."
|
||
),
|
||
"confidence": {
|
||
"triad": "high",
|
||
"type": "medium",
|
||
"variant": "medium",
|
||
"ikigai": "high",
|
||
},
|
||
"extraction_notes": "",
|
||
}
|
||
)
|
||
|
||
|
||
def make_extractor(responses) -> DiscoveryExtractor:
|
||
extractor = DiscoveryExtractor(api_key="test-key")
|
||
extractor.client = FakeClient(responses)
|
||
return extractor
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_gut_type_extraction():
|
||
responses = load_fixture("gut_type_responses.json")
|
||
extractor = make_extractor([make_profile_json("gut", 8, 9)])
|
||
|
||
result = await extractor.extract(responses)
|
||
|
||
assert result["triad"] == "gut"
|
||
assert result["probable_type"] in (8, 9, 1)
|
||
# the model was actually called and the friction story was in the prompt
|
||
sent = extractor.client.messages.calls[0]["messages"][0]["content"]
|
||
assert "The Friction Moment" in sent
|
||
assert "regional manager" in sent
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_heart_type_extraction():
|
||
responses = load_fixture("heart_type_responses.json")
|
||
extractor = make_extractor([make_profile_json("heart", 2, 3)])
|
||
|
||
result = await extractor.extract(responses)
|
||
|
||
assert result["triad"] == "heart"
|
||
assert result["probable_type"] in (2, 3, 4)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_head_type_extraction():
|
||
responses = load_fixture("head_type_responses.json")
|
||
extractor = make_extractor([make_profile_json("head", 5, 6)])
|
||
|
||
result = await extractor.extract(responses)
|
||
|
||
assert result["triad"] == "head"
|
||
assert result["probable_type"] in (5, 6, 7)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_json_parse_failure_retry():
|
||
"""An invalid first response triggers exactly one retry, then succeeds."""
|
||
valid = make_profile_json("head", 5, 6)
|
||
extractor = make_extractor(["here is your profile: not-json!!!", valid])
|
||
responses = load_fixture("head_type_responses.json")
|
||
|
||
result = await extractor.extract(responses)
|
||
|
||
assert result["triad"] == "head"
|
||
assert len(extractor.client.messages.calls) == 2
|
||
# the retry message includes an explicit JSON-only reminder
|
||
retry_content = extractor.client.messages.calls[1]["messages"][0]["content"]
|
||
assert "JSON" in retry_content
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_retry_exhausted_raises():
|
||
"""Two unparseable responses surface a DiscoveryExtractionError."""
|
||
extractor = make_extractor(["nope", "still not json"])
|
||
with pytest.raises(DiscoveryExtractionError):
|
||
await extractor.extract(load_fixture("gut_type_responses.json"))
|
||
assert len(extractor.client.messages.calls) == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_overlap_narrative_present():
|
||
cases = [
|
||
("gut_type_responses.json", "gut", 8, 9),
|
||
("heart_type_responses.json", "heart", 2, 3),
|
||
("head_type_responses.json", "head", 5, 6),
|
||
]
|
||
for fixture, triad, ptype, wing in cases:
|
||
extractor = make_extractor([make_profile_json(triad, ptype, wing)])
|
||
result = await extractor.extract(load_fixture(fixture))
|
||
assert isinstance(result["overlap_narrative"], str)
|
||
assert result["overlap_narrative"].strip() != ""
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_goal_fields_extracted():
|
||
"""Both goal horizons are returned and the goal prompts reach the model."""
|
||
responses = load_fixture("gut_type_responses.json")
|
||
extractor = make_extractor([make_profile_json("gut", 8, 9)])
|
||
|
||
result = await extractor.extract(responses)
|
||
|
||
assert result["short_term_goals"].strip() != ""
|
||
assert result["long_term_goals"].strip() != ""
|
||
# the goal responses were labelled and included in the prompt
|
||
sent = extractor.client.messages.calls[0]["messages"][0]["content"]
|
||
assert "Near-Term Goals (6–12 months)" in sent
|
||
assert "Long-Term Goals (3–5 years)" in sent
|
||
assert "first cohort" in sent # short-term goal text from the fixture
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_missing_goal_keys_trigger_retry():
|
||
"""A profile lacking the goal fields is treated as malformed (one retry)."""
|
||
incomplete = json.loads(make_profile_json("head", 5, 6))
|
||
del incomplete["short_term_goals"]
|
||
del incomplete["long_term_goals"]
|
||
valid = make_profile_json("head", 5, 6)
|
||
extractor = make_extractor([json.dumps(incomplete), valid])
|
||
|
||
result = await extractor.extract(load_fixture("head_type_responses.json"))
|
||
|
||
assert result["short_term_goals"].strip() != ""
|
||
assert len(extractor.client.messages.calls) == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_confidence_flags_present():
|
||
extractor = make_extractor([make_profile_json("heart", 3, 2)])
|
||
result = await extractor.extract(load_fixture("heart_type_responses.json"))
|
||
|
||
confidence = result["confidence"]
|
||
for key in ("triad", "type", "variant", "ikigai"):
|
||
assert key in confidence
|