"""Validated public requests and evidence-constrained LLM schemas.""" from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator ConceptTerm = Annotated[str, Field(min_length=2, max_length=200)] Bucket = Literal[ "effect", "credible_null", "reported_null", "inconclusive", "failed", "unreported" ] EffectType = Literal["SMD", "MD", "logOR", "logRR", "logHR"] class SearchFilters(BaseModel): """Corpus restrictions applied before the search runs. Every bound is independently optional. These narrow the full match set, not the ranking, so they also change bucket counts, the year histogram, the spin rate and the file-drawer share. The response therefore reports them back and the pipeline warns that the numbers describe a subset of the index. """ model_config = ConfigDict(extra="forbid", allow_inf_nan=False) yearFrom: int | None = Field(default=None, ge=1500, le=2100) yearTo: int | None = Field(default=None, ge=1500, le=2100) minCitations: int | None = Field(default=None, ge=0, le=10000000) maxCitations: int | None = Field(default=None, ge=0, le=10000000) @model_validator(mode="after") def _bounds_are_ordered(self): if self.yearFrom is not None and self.yearTo is not None and self.yearFrom > self.yearTo: raise ValueError("yearFrom must not be later than yearTo.") if ( self.minCitations is not None and self.maxCitations is not None and self.minCitations > self.maxCitations ): raise ValueError("minCitations must not exceed maxCitations.") return self @property def active(self) -> bool: return any(value is not None for value in self.model_dump().values()) @property def bounds_citations(self) -> bool: return self.minCitations is not None or self.maxCitations is not None @property def bounds_dates(self) -> bool: return self.yearFrom is not None or self.yearTo is not None class ConceptSteer(BaseModel): """Concepts that aim the same search, rather than a search of their own. Each term is one more direction added to (or subtracted from) the question's own vector before retrieval, so the tags change what ranks highest among the question's matches. Subtracting is a direction, not a filter: a paper is pushed down for being about the term, never removed for mentioning it. """ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) positive: list[ConceptTerm] = Field(default_factory=list, max_length=8) negative: list[ConceptTerm] = Field(default_factory=list, max_length=8) @property def active(self) -> bool: return bool(self.positive or self.negative) class SearchRequest(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False) idea: str = Field(min_length=8, max_length=12000) field: str | None = Field(default=None, max_length=100) sesoi: float | None = Field(default=None, gt=0, le=1000) effectType: EffectType = "SMD" plannedN: int = Field(default=200, ge=4, le=1000000) alpha: float = Field(default=0.05, gt=0, lt=0.5) valueSuccess: float = Field(default=100, ge=0, le=1e12) valueNull: float = Field(default=20, ge=0, le=1e12) studyCost: float = Field(default=30, ge=0, le=1e12) outcomeSd: float | None = Field(default=None, gt=0, le=1e6) baselineRisk: float | None = Field(default=None, gt=0, lt=1) filters: SearchFilters | None = None concepts: ConceptSteer | None = None class Pico(BaseModel): population: str intervention: str comparator: str outcome: str synonyms: list[str] interventionAliases: list[str] = Field(default_factory=list) outcomeAliases: list[str] = Field(default_factory=list) populationAliases: list[str] = Field(default_factory=list) studyDesigns: list[str] sesoi: float = Field(gt=0) sesoiRationale: str effectType: EffectType class NumberEvidence(BaseModel): value: float quote: str class TextEvidence(BaseModel): value: str quote: str class ResultEvidence(BaseModel): """What the report itself states for its primary between-group comparison.""" value: Literal["positive", "null", "mixed"] quote: str Direction = Literal["favours_intervention", "favours_comparator", "unclear"] class DirectionEvidence(BaseModel): """Which arm the reported primary result favors; significance is a separate fact.""" value: Direction quote: str class BoolEvidence(BaseModel): value: bool quote: str class Extraction(BaseModel): """Every returned fact must have verbatim support in the ORIGINAL abstract.""" population: TextEvidence | None intervention: TextEvidence | None comparator: TextEvidence | None outcome: TextEvidence | None outcome_unit: TextEvidence | None design: TextEvidence | None n: NumberEvidence | None n_intervention: NumberEvidence | None n_comparator: NumberEvidence | None mean_intervention: NumberEvidence | None mean_comparator: NumberEvidence | None sd_intervention: NumberEvidence | None sd_comparator: NumberEvidence | None events_intervention: NumberEvidence | None events_comparator: NumberEvidence | None estimate: NumberEvidence | None ci_low: NumberEvidence | None ci_high: NumberEvidence | None ci_level: NumberEvidence | None ci_sides: TextEvidence | None p_value: NumberEvidence | None effect_type: TextEvidence | None primary_outcome_met: BoolEvidence | None reported_result: ResultEvidence | None result_direction: DirectionEvidence | None has_control: BoolEvidence | None class Relevance(BaseModel): """Ids of retrieved studies that actually address the research question.""" relevant_ids: list[str] class OutcomeGroup(BaseModel): label: str study_ids: list[str] class OutcomeGroups(BaseModel): """Studies judged to measure the same outcome for the same kind of comparison.""" groups: list[OutcomeGroup] class StudyNote(BaseModel): id: str note: str class Overview(BaseModel): """What the matched studies are and how each bears on the question.""" summary: str notes: list[StudyNote] class PicoChange(BaseModel): field: Literal["population", "intervention", "comparator", "outcome"] to: str reason: str class PicoRecommendation(BaseModel): """How the asked PICO should change, given what the record shows; empty when it should not.""" changes: list[PicoChange] rationale: str class EffectTrend(BaseModel): summary: str patterns: list[str] class Narrative(BaseModel): summary: str drivers: list[str] Coverage = Literal["tests_claim", "tests_related", "background_only"] NoveltyVerdict = Literal["already_done", "incremental", "open_gap", "insufficient_evidence"] class NoveltyRequest(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False) hypothesis: str = Field(min_length=8, max_length=4000) scan: int = Field(default=120, ge=10, le=400) read: int = Field(default=5, ge=1, le=12) class MapArithmetic(BaseModel): """Embedding arithmetic on idea phrases: start − remove + add. The terms are embedded separately and combined; the map then reports which real papers sit nearest the implied point. It is a way of asking "has this combination been run", not a proof that the analogy holds. """ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False) start: str = Field(min_length=2, max_length=4000) remove: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field( default_factory=list, max_length=6 ) add: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field( default_factory=list, max_length=6 ) @model_validator(mode="after") def _has_an_operation(self): if not self.remove and not self.add: raise ValueError("An expression needs something to remove or to add.") return self class MapRequest(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False) idea: str | None = Field(default=None, min_length=8, max_length=4000) # When both are sent, the expression is placed and the idea is ignored. arithmetic: MapArithmetic | None = None cutoffYear: int | None = Field(default=None, ge=1900, le=2100) refresh: bool = False class ConceptSearchRequest(BaseModel): """Concepts to add and to subtract before the index is searched by nearest neighbor. The negatives are part of the query direction, not a post-filter: a paper is pushed away by a subtracted concept rather than removed for mentioning it. """ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False) positive: list[ConceptTerm] = Field(min_length=1, max_length=8) negative: list[ConceptTerm] = Field(default_factory=list, max_length=8) limit: int = Field(default=20, ge=1, le=50) filters: SearchFilters | None = None class Claim(BaseModel): """The testable content of a hypothesis, as retrieval terms and comparable facets.""" intervention: str system: str outcome: str direction: str queries: list[str] synonyms: list[str] class PaperRead(BaseModel): """What one paper's own results say about the claim, quoted from its text.""" coverage: Coverage system_tested: str intervention_tested: str outcome_measured: str finding: str quotes: list[str] facets_settled: list[str] facets_untested: list[str] class NoveltyAssessment(BaseModel): verdict: NoveltyVerdict summary: str gaps: list[str] settled: list[str] class IndexedNumberEvidence(BaseModel): value: float sentence_index: int = Field(ge=0) class IndexedTextEvidence(BaseModel): value: str sentence_index: int = Field(ge=0) class IndexedResultEvidence(BaseModel): value: Literal["positive", "null", "mixed"] sentence_index: int = Field(ge=0) class IndexedDirectionEvidence(BaseModel): value: Direction sentence_index: int = Field(ge=0) class IndexedBoolEvidence(BaseModel): value: bool sentence_index: int = Field(ge=0) class IndexedExtraction(BaseModel): """Select an original sentence; application code supplies its exact quote.""" population: IndexedTextEvidence | None intervention: IndexedTextEvidence | None comparator: IndexedTextEvidence | None outcome: IndexedTextEvidence | None outcome_unit: IndexedTextEvidence | None design: IndexedTextEvidence | None n: IndexedNumberEvidence | None n_intervention: IndexedNumberEvidence | None n_comparator: IndexedNumberEvidence | None mean_intervention: IndexedNumberEvidence | None mean_comparator: IndexedNumberEvidence | None sd_intervention: IndexedNumberEvidence | None sd_comparator: IndexedNumberEvidence | None events_intervention: IndexedNumberEvidence | None events_comparator: IndexedNumberEvidence | None estimate: IndexedNumberEvidence | None ci_low: IndexedNumberEvidence | None ci_high: IndexedNumberEvidence | None ci_level: IndexedNumberEvidence | None ci_sides: IndexedTextEvidence | None p_value: IndexedNumberEvidence | None effect_type: IndexedTextEvidence | None primary_outcome_met: IndexedBoolEvidence | None reported_result: IndexedResultEvidence | None result_direction: IndexedDirectionEvidence | None has_control: IndexedBoolEvidence | None