"""Topic and Term routing through the provider-neutral model interface.""" from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field from gcmd_classifier.classification.candidates import ( TermCandidate, TopicCandidate, build_term_candidates, build_topic_candidates, validate_term_candidate_relationship, ) from gcmd_classifier.config import ModelSettings from gcmd_classifier.errors import UnknownCandidateIDError from gcmd_classifier.llm.base import ( ModelClient, ModelRequest, ModelStage, RetryPolicy, generate_with_retries, ) from gcmd_classifier.llm.prompts import ParentContext, build_term_prompt, build_topic_prompt from gcmd_classifier.llm.schemas import CandidateDecision, TermResponse, TopicResponse from gcmd_classifier.models import ArticleRecord, OutputError, OutputWarning, SupportType from gcmd_classifier.vocabulary.index import VocabularyIndex class ModelCallMetadata(BaseModel): """Safe model-call metadata retained in routing results.""" model_config = ConfigDict(extra="forbid", frozen=True) provider: str model_name: str prompt_version: str retry_count: int = Field(ge=0) duration_seconds: float | None = Field(default=None, ge=0.0) input_tokens: int | None = Field(default=None, ge=0) output_tokens: int | None = Field(default=None, ge=0) total_tokens: int | None = Field(default=None, ge=0) estimated_cost: float | None = Field(default=None, ge=0.0) class TopicBranchSeed(BaseModel): """Seed for a later Term-routing branch under one selected Topic.""" model_config = ConfigDict(extra="forbid", frozen=True) branch_id: str = Field(min_length=1) topic_uuid: str = Field(min_length=1) topic_name: str = Field(min_length=1) topic_level: str = Field(pattern="^Topic$") topic_canonical_path: str = Field(min_length=1) evidence: str = Field(min_length=1) support_type: SupportType confidence: float | None = Field(default=None, ge=0.0, le=1.0) reason: str | None = None candidate_id: str = Field(min_length=1) prompt_version: str model_provider: str model_name: str retry_count: int = Field(ge=0) class TopicRoutingResult(BaseModel): """Result of Topic routing for one article.""" model_config = ConfigDict(extra="forbid", frozen=True) branches: tuple[TopicBranchSeed, ...] = Field(default_factory=tuple) no_selection_reason: str | None = None model_metadata: ModelCallMetadata warnings: tuple[OutputWarning, ...] = Field(default_factory=tuple) errors: tuple[OutputError, ...] = Field(default_factory=tuple) @property def selected_count(self) -> int: """Number of selected Topic branches.""" return len(self.branches) @property def is_no_topic(self) -> bool: """Whether routing completed successfully with no selected Topics.""" return not self.branches class TermBranchSeed(BaseModel): """Seed for a later Variable-routing branch under one selected Term.""" model_config = ConfigDict(extra="forbid", frozen=True) branch_id: str = Field(min_length=1) parent_topic_uuid: str = Field(min_length=1) parent_topic_name: str = Field(min_length=1) term_uuid: str = Field(min_length=1) term_name: str = Field(min_length=1) term_level: str = Field(pattern="^Term$") term_canonical_path: str = Field(min_length=1) evidence: str = Field(min_length=1) support_type: SupportType confidence: float | None = Field(default=None, ge=0.0, le=1.0) reason: str | None = None candidate_id: str = Field(min_length=1) parent_branch_id: str = Field(min_length=1) prompt_version: str model_provider: str model_name: str retry_count: int = Field(ge=0) class TopicStopResult(BaseModel): """Successful Term-routing stop at the selected Topic parent.""" model_config = ConfigDict(extra="forbid", frozen=True) branch_id: str = Field(min_length=1) topic_uuid: str = Field(min_length=1) topic_name: str = Field(min_length=1) topic_level: str = Field(pattern="^Topic$") topic_canonical_path: str = Field(min_length=1) stop_reason: str = Field(min_length=1) parent_evidence: str = Field(min_length=1) parent_support_type: SupportType parent_confidence: float | None = Field(default=None, ge=0.0, le=1.0) topic_candidate_id: str = Field(min_length=1) prompt_version: str model_provider: str model_name: str retry_count: int = Field(ge=0) class TermRoutingResult(BaseModel): """Result of Term routing under one selected Topic branch.""" model_config = ConfigDict(extra="forbid", frozen=True) term_branches: tuple[TermBranchSeed, ...] = Field(default_factory=tuple) stop_at_topic: TopicStopResult | None = None model_metadata: ModelCallMetadata warnings: tuple[OutputWarning, ...] = Field(default_factory=tuple) errors: tuple[OutputError, ...] = Field(default_factory=tuple) @property def selected_count(self) -> int: """Number of selected Term branches.""" return len(self.term_branches) @property def stopped_at_topic(self) -> bool: """Whether routing stopped successfully at the parent Topic.""" return self.stop_at_topic is not None def route_topics( *, article: ArticleRecord, vocabulary: VocabularyIndex, model_client: ModelClient, settings: ModelSettings, retry_policy: RetryPolicy | None = None, ) -> TopicRoutingResult: """Route an article to zero, one, or more valid GCMD Topics.""" candidates = build_topic_candidates(vocabulary) candidates_by_id = {candidate.candidate_id: candidate for candidate in candidates} prompt = build_topic_prompt( article=article, candidates=tuple(candidate.prompt_candidate for candidate in candidates), prompt_version=settings.prompt_version_topic, ) request = ModelRequest.from_settings( stage=ModelStage.TOPIC, prompt=prompt, response_schema=TopicResponse, settings=settings, metadata={ "DOI": article.DOI, "candidate_ids": tuple(candidates_by_id), }, ) response = generate_with_retries( model_client, request, retry_policy or RetryPolicy.from_settings(settings), ) _validate_selected_candidate_ids(response.parsed.selected, candidates_by_id, stage="Topic") metadata = _model_metadata(response) branches = tuple( _topic_branch_seed( decision=decision, candidate=candidates_by_id[decision.candidate_id], vocabulary=vocabulary, metadata=metadata, ) for decision in response.parsed.selected ) return TopicRoutingResult( branches=branches, no_selection_reason=response.parsed.no_selection_reason if not branches else None, model_metadata=metadata, ) def route_terms( *, article: ArticleRecord, topic_branch: TopicBranchSeed, vocabulary: VocabularyIndex, model_client: ModelClient, settings: ModelSettings, retry_policy: RetryPolicy | None = None, ) -> TermRoutingResult: """Route one selected Topic branch to zero, one, or more direct Term children.""" candidates = build_term_candidates(vocabulary, topic_uuid=topic_branch.topic_uuid) candidates_by_id = {candidate.candidate_id: candidate for candidate in candidates} parent_context = ParentContext( candidate_id=topic_branch.candidate_id, name=topic_branch.topic_name, level="Topic", canonical_path=topic_branch.topic_canonical_path, ) prompt = build_term_prompt( article=article, parent=parent_context, candidates=tuple(candidate.prompt_candidate for candidate in candidates), prompt_version=settings.prompt_version_term, ) request = ModelRequest.from_settings( stage=ModelStage.TERM, prompt=prompt, response_schema=TermResponse, settings=settings, metadata={ "DOI": article.DOI, "parent_topic_uuid": topic_branch.topic_uuid, "parent_branch_id": topic_branch.branch_id, "candidate_ids": tuple(candidates_by_id), }, ) response = generate_with_retries( model_client, request, retry_policy or RetryPolicy.from_settings(settings), ) _validate_selected_candidate_ids(response.parsed.selected, candidates_by_id, stage="Term") metadata = _model_metadata(response) term_branches = tuple( _term_branch_seed( decision=decision, candidate=candidates_by_id[decision.candidate_id], topic_branch=topic_branch, vocabulary=vocabulary, metadata=metadata, ) for decision in response.parsed.selected ) stop_at_topic = None if response.parsed.stop_at_parent: stop_at_topic = _topic_stop_result( topic_branch=topic_branch, stop_reason=response.parsed.stop_reason, metadata=metadata, ) return TermRoutingResult( term_branches=term_branches, stop_at_topic=stop_at_topic, model_metadata=metadata, ) def _validate_selected_candidate_ids( selected: list[CandidateDecision], candidates_by_id: dict[str, TopicCandidate] | dict[str, TermCandidate], *, stage: str, ) -> None: seen: set[str] = set() for decision in selected: if decision.candidate_id not in candidates_by_id: raise UnknownCandidateIDError( f"{stage} model selected unknown candidate_id {decision.candidate_id!r}." ) if decision.candidate_id in seen: raise UnknownCandidateIDError( f"{stage} model selected duplicate candidate_id {decision.candidate_id!r}." ) seen.add(decision.candidate_id) def _topic_branch_seed( *, decision: CandidateDecision, candidate: TopicCandidate, vocabulary: VocabularyIndex, metadata: ModelCallMetadata, ) -> TopicBranchSeed: topic = vocabulary.get(candidate.topic_uuid) return TopicBranchSeed( branch_id=f"topic:{candidate.candidate_id}", topic_uuid=topic.UUID, topic_name=topic.name, topic_level=topic.level, topic_canonical_path=topic.canonical_path, evidence=decision.evidence, support_type=decision.support_type, confidence=decision.confidence, reason=decision.reason, candidate_id=decision.candidate_id, prompt_version=metadata.prompt_version, model_provider=metadata.provider, model_name=metadata.model_name, retry_count=metadata.retry_count, ) def _term_branch_seed( *, decision: CandidateDecision, candidate: TermCandidate, topic_branch: TopicBranchSeed, vocabulary: VocabularyIndex, metadata: ModelCallMetadata, ) -> TermBranchSeed: validate_term_candidate_relationship( candidate, selected_topic_uuid=topic_branch.topic_uuid, index=vocabulary, ) term = vocabulary.get(candidate.term_uuid) return TermBranchSeed( branch_id=f"{topic_branch.branch_id}/term:{candidate.candidate_id}", parent_topic_uuid=topic_branch.topic_uuid, parent_topic_name=topic_branch.topic_name, term_uuid=term.UUID, term_name=term.name, term_level=term.level, term_canonical_path=term.canonical_path, evidence=decision.evidence, support_type=decision.support_type, confidence=decision.confidence, reason=decision.reason, candidate_id=decision.candidate_id, parent_branch_id=topic_branch.branch_id, prompt_version=metadata.prompt_version, model_provider=metadata.provider, model_name=metadata.model_name, retry_count=metadata.retry_count, ) def _topic_stop_result( *, topic_branch: TopicBranchSeed, stop_reason: str | None, metadata: ModelCallMetadata, ) -> TopicStopResult: if not stop_reason: raise ValueError("stop_at_parent responses require a stop_reason.") return TopicStopResult( branch_id=topic_branch.branch_id, topic_uuid=topic_branch.topic_uuid, topic_name=topic_branch.topic_name, topic_level=topic_branch.topic_level, topic_canonical_path=topic_branch.topic_canonical_path, stop_reason=stop_reason, parent_evidence=topic_branch.evidence, parent_support_type=topic_branch.support_type, parent_confidence=topic_branch.confidence, topic_candidate_id=topic_branch.candidate_id, prompt_version=metadata.prompt_version, model_provider=metadata.provider, model_name=metadata.model_name, retry_count=metadata.retry_count, ) def _model_metadata(response) -> ModelCallMetadata: token_usage = response.token_usage return ModelCallMetadata( provider=response.provider, model_name=response.model_name, prompt_version=response.prompt_version, retry_count=response.retry_count, duration_seconds=response.duration_seconds, input_tokens=None if token_usage is None else token_usage.input_tokens, output_tokens=None if token_usage is None else token_usage.output_tokens, total_tokens=None if token_usage is None else token_usage.total_tokens, estimated_cost=response.estimated_cost, )