This commit is contained in:
1 parent
0e1a2ad008
commit
34f4923566
28 files changed
+3932
-81
No files matched your search
@@ -26,6 +26,7 @@ ResearchLens = Literal[
|
||||
"financial-market",
|
||||
]
|
||||
FacetName = Literal["tags", "audience", "use_for", "domain"]
|
||||
FacetLabelStatus = Literal["active", "merged", "retired"]
|
||||
DEFAULT_RESEARCH_LENSES: tuple[ResearchLens, ...] = (
|
||||
"technical",
|
||||
"conceptual",
|
||||
@@ -49,6 +50,17 @@ Sha256 = Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{64}$")]
|
||||
GitCommitId = Annotated[str, StringConstraints(pattern=r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")]
|
||||
|
||||
|
||||
def _normalized_facet_label(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFC", value).casefold().split())
|
||||
|
||||
|
||||
def _clean_facet_label(value: str) -> str:
|
||||
cleaned = " ".join(unicodedata.normalize("NFC", value).split())
|
||||
if not cleaned:
|
||||
raise ValueError("Facet labels must contain non-whitespace text")
|
||||
return cleaned
|
||||
|
||||
|
||||
class StrictRecord(BaseModel):
|
||||
"""Base for public records: reject implicit coercion and unknown fields."""
|
||||
|
||||
@@ -63,6 +75,45 @@ class ProjectStatus(StrEnum):
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class ProjectFacetLabel(StrictRecord):
|
||||
label_id: RecordId
|
||||
facet: FacetName
|
||||
canonical_label: SearchFacetValue
|
||||
definition: str = Field(min_length=1, max_length=500)
|
||||
aliases: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
||||
status: FacetLabelStatus = "active"
|
||||
merged_into: RecordId | None = None
|
||||
origin_review_id: RecordId | None = None
|
||||
jev_model: str | None = Field(default=None, max_length=100)
|
||||
jev_fit_probability: FiniteFloat | None = Field(default=None, ge=0, le=1)
|
||||
jev_new_probability: FiniteFloat | None = Field(default=None, ge=0, le=1)
|
||||
created_at: datetime
|
||||
|
||||
@field_validator("canonical_label")
|
||||
@classmethod
|
||||
def normalize_canonical_label(cls, value: str) -> str:
|
||||
return _clean_facet_label(value)
|
||||
|
||||
@field_validator("aliases")
|
||||
@classmethod
|
||||
def normalize_aliases(cls, values: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(_clean_facet_label(value) for value in values)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_label_lifecycle(self) -> ProjectFacetLabel:
|
||||
if self.status == "merged" and self.merged_into is None:
|
||||
raise ValueError("Merged facet labels require a canonical target")
|
||||
if self.status != "merged" and self.merged_into is not None:
|
||||
raise ValueError("Only merged facet labels may have a merge target")
|
||||
names = (self.canonical_label, *self.aliases)
|
||||
if len({_normalized_facet_label(name) for name in names}) != len(names):
|
||||
raise ValueError("Facet label and aliases must be unique")
|
||||
offset = self.created_at.utcoffset()
|
||||
if offset is None or offset.total_seconds() != 0:
|
||||
raise ValueError("Facet label timestamp must be timezone-aware UTC")
|
||||
return self
|
||||
|
||||
|
||||
class ProjectRecord(StrictRecord):
|
||||
project_id: RecordId
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
@@ -75,6 +126,7 @@ class ProjectRecord(StrictRecord):
|
||||
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
|
||||
default_includes: tuple[RecordId, ...] = ("shared",)
|
||||
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
|
||||
facet_labels: tuple[ProjectFacetLabel, ...] = Field(default=(), max_length=2000)
|
||||
autonomy_policy_id: RecordId
|
||||
|
||||
@field_validator("project_id")
|
||||
@@ -109,6 +161,7 @@ class ProjectLens(StrictRecord):
|
||||
research_lenses: tuple[ResearchLens, ...] = DEFAULT_RESEARCH_LENSES
|
||||
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
|
||||
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
|
||||
facet_labels: tuple[ProjectFacetLabel, ...] = Field(default=(), max_length=2000)
|
||||
ingest_guidance: tuple[IngestGuidance, ...] = Field(default=(), max_length=100)
|
||||
|
||||
|
||||
@@ -122,6 +175,45 @@ class JevFacetScore(StrictRecord):
|
||||
probability: FiniteFloat = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class JevFacetOptionScore(StrictRecord):
|
||||
label_id: RecordId | None = None
|
||||
label: SearchFacetValue
|
||||
probability: FiniteFloat = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class JevFacetResolution(StrictRecord):
|
||||
facet: FacetName
|
||||
value: SearchFacetValue
|
||||
fit_probability: FiniteFloat = Field(ge=0, le=1)
|
||||
selected_label_id: RecordId | None = None
|
||||
selected_label: SearchFacetValue | None = None
|
||||
selected_new_label: bool = False
|
||||
new_probability: FiniteFloat = Field(ge=0, le=1)
|
||||
choice_confidence: FiniteFloat = Field(ge=0, le=1)
|
||||
option_probabilities: tuple[JevFacetOptionScore, ...] = Field(default=(), max_length=6)
|
||||
rank: int = Field(ge=1, le=20)
|
||||
added_to_project: bool = False
|
||||
|
||||
|
||||
class FacetLabelProposal(StrictRecord):
|
||||
facet: FacetName
|
||||
value: SearchFacetValue
|
||||
definition: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def normalize_value(cls, value: str) -> str:
|
||||
return _clean_facet_label(value)
|
||||
|
||||
@field_validator("definition")
|
||||
@classmethod
|
||||
def normalize_definition(cls, value: str) -> str:
|
||||
definition = value.strip()
|
||||
if not definition:
|
||||
raise ValueError("Facet label definitions must contain non-whitespace text")
|
||||
return definition
|
||||
|
||||
|
||||
class IngestReviewItem(StrictRecord):
|
||||
review_id: RecordId
|
||||
project_id: RecordId
|
||||
@@ -135,6 +227,7 @@ class IngestReviewItem(StrictRecord):
|
||||
relevance_rationale: str = Field(min_length=1, max_length=2000)
|
||||
relevance: Literal["borderline", "low"] = "borderline"
|
||||
jev_facet_scores: tuple[JevFacetScore, ...] = Field(default=(), max_length=20)
|
||||
jev_facet_resolutions: tuple[JevFacetResolution, ...] = Field(default=(), max_length=20)
|
||||
jev_relation_probability: FiniteFloat | None = Field(default=None, ge=0, le=1)
|
||||
jev_lens: ResearchLens | None = None
|
||||
jev_lens_confidence: FiniteFloat | None = Field(default=None, ge=0, le=1)
|
||||
@@ -165,9 +258,10 @@ class IngestReviewCreate(StrictRecord):
|
||||
relevance_rationale: str = Field(min_length=1, max_length=2000)
|
||||
relevance: Literal["borderline", "low"] = "borderline"
|
||||
candidate_tags: tuple[SearchFacetValue, ...] = Field(default=(), max_length=5)
|
||||
candidate_audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=5)
|
||||
candidate_use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=5)
|
||||
candidate_domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=5)
|
||||
candidate_audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=3)
|
||||
candidate_use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=3)
|
||||
candidate_domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=3)
|
||||
candidate_facet_labels: tuple[FacetLabelProposal, ...] = Field(default=(), max_length=14)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def limit_total_facet_candidates(self) -> IngestReviewCreate:
|
||||
@@ -177,17 +271,55 @@ class IngestReviewCreate(StrictRecord):
|
||||
*self.candidate_use_for,
|
||||
*self.candidate_domain,
|
||||
)
|
||||
if len(values) > 20:
|
||||
raise ValueError("At most 20 facet candidates may be assessed")
|
||||
if len({(facet, value.casefold()) for facet, candidates in (
|
||||
proposed = (*values, *(proposal.value for proposal in self.candidate_facet_labels))
|
||||
if len(proposed) > 14:
|
||||
raise ValueError("At most 14 facet candidates may be assessed")
|
||||
facet_limits = {"tags": 5, "audience": 3, "use_for": 3, "domain": 3}
|
||||
facet_counts = {facet: 0 for facet in facet_limits}
|
||||
for facet, candidates in (
|
||||
("tags", self.candidate_tags),
|
||||
("audience", self.candidate_audience),
|
||||
("use_for", self.candidate_use_for),
|
||||
("domain", self.candidate_domain),
|
||||
) for value in candidates}) != len(values):
|
||||
):
|
||||
facet_counts[facet] += len(candidates)
|
||||
for proposal in self.candidate_facet_labels:
|
||||
facet_counts[proposal.facet] += 1
|
||||
if any(facet_counts[facet] > limit for facet, limit in facet_limits.items()):
|
||||
raise ValueError("Facet candidates exceed the per-facet limits (tags: 5; others: 3)")
|
||||
keyed = {
|
||||
(facet, _normalized_facet_label(value))
|
||||
for facet, candidates in (
|
||||
("tags", self.candidate_tags),
|
||||
("audience", self.candidate_audience),
|
||||
("use_for", self.candidate_use_for),
|
||||
("domain", self.candidate_domain),
|
||||
)
|
||||
for value in candidates
|
||||
}
|
||||
keyed.update(
|
||||
(proposal.facet, _normalized_facet_label(proposal.value))
|
||||
for proposal in self.candidate_facet_labels
|
||||
)
|
||||
if len(keyed) != len(proposed):
|
||||
raise ValueError("Facet candidates must be unique within each facet")
|
||||
return self
|
||||
|
||||
def facet_proposals(self) -> tuple[FacetLabelProposal, ...]:
|
||||
proposals: list[FacetLabelProposal] = list(self.candidate_facet_labels)
|
||||
fields: tuple[tuple[FacetName, tuple[SearchFacetValue, ...]], ...] = (
|
||||
("tags", self.candidate_tags),
|
||||
("audience", self.candidate_audience),
|
||||
("use_for", self.candidate_use_for),
|
||||
("domain", self.candidate_domain),
|
||||
)
|
||||
for facet, values in fields:
|
||||
proposals.extend(
|
||||
FacetLabelProposal(facet=facet, value=value, definition=self.summary[:500])
|
||||
for value in values
|
||||
)
|
||||
return tuple(proposals)
|
||||
|
||||
|
||||
class IngestReviewDecision(StrictRecord):
|
||||
decision_id: RecordId
|
||||
|
||||
Reference in new issue
Block a user