Spaces:
Running on Zero
Running on Zero
| """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) | |
| def selected_count(self) -> int: | |
| """Number of selected Topic branches.""" | |
| return len(self.branches) | |
| 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) | |
| def selected_count(self) -> int: | |
| """Number of selected Term branches.""" | |
| return len(self.term_branches) | |
| 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, | |
| ) | |