Files
impactflow_discovery/tests/test_extractor.py
T
Joel Salmon 33674f92f4 Complete Phase 1: goals, cookie auth, profile editing
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>
2026-06-15 18:26:08 -05:00

214 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (612 months)" in sent
assert "Long-Term Goals (35 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