nullmap-api / app /models.py
Conradf's picture
Upload folder using huggingface_hub
285b041 verified
Raw History Blame Contribute Delete
11.8 kB
"""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