"""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." ), "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_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