from __future__ import annotations from pathlib import Path import pytest from gcmd_classifier.classification import build_topic_candidates, route_topics from gcmd_classifier.config import ModelSettings from gcmd_classifier.errors import StructuredModelResponseError, UnknownCandidateIDError from gcmd_classifier.llm import FakeModelClient from gcmd_classifier.models import ArticleRecord, SupportType from gcmd_classifier.vocabulary import load_vocabulary FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json") FULL_HIERARCHY_PATH = Path("data/gcmd_hierarchy.json") def _index(): return load_vocabulary(FIXTURE_PATH) def _article( abstract: str = "Atmospheric chemistry and ocean carbon are discussed.", ) -> ArticleRecord: return ArticleRecord( DOI="10.example/topic-routing", Title="Atmospheric and ocean observations", Year=2025, Abstract=abstract, ) def _decision(candidate_id: str, confidence: float | None = 0.82) -> dict: return { "candidate_id": candidate_id, "confidence": confidence, "evidence": "The title and abstract support this Topic.", "support_type": "explicit", "reason": "Primary subject is represented by this Topic.", } def test_topic_candidates_are_built_from_vocabulary_index() -> None: index = _index() candidates = build_topic_candidates(index) assert len(candidates) == 2 assert [candidate.prompt_candidate.name for candidate in candidates] == [ record.name for record in index.topics() ] def test_root_category_is_never_included_as_candidate() -> None: candidates = build_topic_candidates(_index()) assert all(candidate.prompt_candidate.level == "Topic" for candidate in candidates) assert all(candidate.prompt_candidate.name != "EARTH SCIENCE" for candidate in candidates) def test_every_topic_candidate_maps_to_uuid_bearing_topic_record() -> None: index = _index() for candidate in build_topic_candidates(index): record = index.get(candidate.topic_uuid) assert record.UUID assert record.level == "Topic" assert candidate.prompt_candidate.candidate_id == candidate.candidate_id def test_candidate_ids_are_unique_and_stable() -> None: first = build_topic_candidates(_index()) second = build_topic_candidates(_index()) first_ids = [candidate.candidate_id for candidate in first] second_ids = [candidate.candidate_id for candidate in second] assert first_ids == second_ids assert len(first_ids) == len(set(first_ids)) assert first_ids == ["topic_0001", "topic_0002"] def test_candidate_construction_does_not_hard_code_topic_names_or_uuids() -> None: index = _index() candidates = build_topic_candidates(index) assert [candidate.topic_uuid for candidate in candidates] == [ topic.UUID for topic in index.topics() ] assert [candidate.prompt_candidate.name for candidate in candidates] == [ topic.name for topic in index.topics() ] def test_single_topic_selection() -> None: index = _index() candidates = build_topic_candidates(index) client = FakeModelClient([{"selected": [_decision(candidates[0].candidate_id)]}]) result = route_topics( article=_article(), vocabulary=index, model_client=client, settings=ModelSettings(), ) assert result.selected_count == 1 assert result.is_no_topic is False assert result.branches[0].topic_uuid == candidates[0].topic_uuid def test_multiple_topic_selection() -> None: index = _index() candidates = build_topic_candidates(index) client = FakeModelClient( [ { "selected": [ _decision(candidates[0].candidate_id), _decision(candidates[1].candidate_id, confidence=None), ] } ] ) result = route_topics( article=_article(), vocabulary=index, model_client=client, settings=ModelSettings(), ) assert [branch.topic_uuid for branch in result.branches] == [ candidates[0].topic_uuid, candidates[1].topic_uuid, ] assert result.branches[1].confidence is None def test_no_topic_selection_with_reason() -> None: client = FakeModelClient( [ { "selected": [], "ambiguous_alternatives": [], "no_selection_reason": "No supplied Topic is defensible.", } ] ) result = route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) assert result.branches == () assert result.is_no_topic is True assert result.no_selection_reason == "No supplied Topic is defensible." def test_invalid_candidate_id_returned_by_fake_model_is_rejected() -> None: client = FakeModelClient([{"selected": [_decision("unknown-topic")]}]) with pytest.raises(UnknownCandidateIDError): route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) def test_empty_or_malformed_candidate_id_rejected_by_structured_schema() -> None: client = FakeModelClient([{"selected": [_decision("")]}]) with pytest.raises(StructuredModelResponseError): route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) def test_duplicate_candidate_id_returned_by_model_is_rejected() -> None: candidate_id = build_topic_candidates(_index())[0].candidate_id client = FakeModelClient([{"selected": [_decision(candidate_id), _decision(candidate_id)]}]) with pytest.raises(UnknownCandidateIDError): route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) def test_selected_topic_branch_seed_is_populated_from_vocabulary_index() -> None: index = _index() candidate = build_topic_candidates(index)[0] record = index.get(candidate.topic_uuid) client = FakeModelClient([{"selected": [_decision(candidate.candidate_id, confidence=0.73)]}]) result = route_topics( article=_article(), vocabulary=index, model_client=client, settings=ModelSettings(model_name="unit-test-model", prompt_version_topic="topic-test"), ) branch = result.branches[0] assert branch.branch_id == f"topic:{candidate.candidate_id}" assert branch.topic_uuid == record.UUID assert branch.topic_name == record.name assert branch.topic_level == "Topic" assert branch.topic_canonical_path == record.canonical_path assert branch.evidence == "The title and abstract support this Topic." assert branch.support_type is SupportType.EXPLICIT assert branch.confidence == 0.73 assert branch.reason == "Primary subject is represented by this Topic." assert branch.candidate_id == candidate.candidate_id assert branch.prompt_version == "topic-test" assert branch.model_name == "unit-test-model" def test_confidence_is_preserved_as_uncalibrated_metadata_only() -> None: candidate = build_topic_candidates(_index())[0] client = FakeModelClient([{"selected": [_decision(candidate.candidate_id, confidence=0.01)]}]) result = route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) assert result.branches[0].confidence == 0.01 assert result.branches[0].topic_uuid == candidate.topic_uuid def test_no_fake_fallback_topic_is_created() -> None: client = FakeModelClient([{"selected": [], "no_selection_reason": "No Topic."}]) result = route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) assert result.branches == () assert "FALLBACK" not in str(result.model_dump()) def test_no_term_or_variable_routing_is_performed() -> None: candidate = build_topic_candidates(_index())[0] client = FakeModelClient([{"selected": [_decision(candidate.candidate_id)]}]) route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) assert len(client.requests) == 1 assert client.requests[0].stage.value == "topic" def test_topic_router_uses_provider_neutral_model_interface() -> None: candidate = build_topic_candidates(_index())[0] client = FakeModelClient([{"selected": [_decision(candidate.candidate_id)]}]) route_topics( article=_article(), vocabulary=_index(), model_client=client, settings=ModelSettings(provider="fake-provider", model_name="fake-model"), ) request = client.requests[0] assert request.provider == "fake-provider" assert request.model_name == "fake-model" assert request.response_schema.__name__ == "TopicResponse" def test_fake_model_receives_article_fields_and_topic_candidates() -> None: index = _index() candidate = build_topic_candidates(index)[0] article = _article() client = FakeModelClient([{"selected": [_decision(candidate.candidate_id)]}]) route_topics( article=article, vocabulary=index, model_client=client, settings=ModelSettings(), ) prompt = client.requests[0].prompt assert article.DOI in prompt assert article.Title in prompt assert article.Abstract in prompt assert candidate.candidate_id in prompt assert index.get(candidate.topic_uuid).name in prompt def test_empty_abstract_remains_valid_and_is_passed_to_prompt() -> None: candidate = build_topic_candidates(_index())[0] client = FakeModelClient([{"selected": [_decision(candidate.candidate_id)]}]) route_topics( article=_article(abstract=""), vocabulary=_index(), model_client=client, settings=ModelSettings(), ) assert "\n\n" in client.requests[0].prompt def test_full_data_current_vocabulary_has_14_topic_candidates() -> None: index = load_vocabulary(FULL_HIERARCHY_PATH) candidates = build_topic_candidates(index) assert len(candidates) == 14 assert all(index.get(candidate.topic_uuid).UUID for candidate in candidates) assert all(index.get(candidate.topic_uuid).level == "Topic" for candidate in candidates)