feat(ingest): resolve project labels with Jev
CI / quality (push) Canceled after 0s

This commit is contained in:
vasily committed 2026-10-15 10:53:30 +08:00
1 parent 0e1a2ad008
commit 34f4923566
28 files changed
+3932 -81

No files matched your search

+139 -7
View File
@@ -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
+1
View File
@@ -69,6 +69,7 @@ OPERATIONS: Final[tuple[Operation, ...]] = (
Operation("source_extract", "proposal:submit", frozenset({Transport.MCP})),
Operation("source_extraction_block", "proposal:submit", frozenset({Transport.MCP})),
Operation("proposal_submit", "proposal:submit", frozenset({Transport.MCP}), True),
Operation("proposal_prepare", "proposal:evaluate", frozenset({Transport.MCP})),
Operation("proposal_evaluate", "proposal:evaluate", frozenset({Transport.MCP})),
Operation("proposal_status", "proposal:read", frozenset({Transport.MCP})),
Operation("proposal_withdraw", "proposal:withdraw", frozenset({Transport.MCP})),
+69
View File
@@ -6,7 +6,9 @@ import hashlib
import json
import unicodedata
from collections.abc import Iterable
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from gewu.domain.contracts import (
AutoAcceptPolicy,
@@ -59,6 +61,73 @@ def proposal_payload_digest(proposal: ProposalRevision) -> str:
return hashlib.sha256(canonical_json(operations)).hexdigest()
def prepare_proposal_digests(draft: dict[str, Any]) -> ProposalRevision:
"""Validate a proposal draft, fill its dependent evidence digests, and pin its payload."""
value = deepcopy(draft)
operations = value.get("operations")
if not isinstance(operations, list) or not operations:
raise ValueError("Proposal must contain a non-empty operations array")
for operation in operations:
if not isinstance(operation, dict):
raise ValueError("Proposal operations must be objects")
node = operation.get("node")
if isinstance(node, dict):
for group in ("claims", "assumptions", "limitations"):
claims = node.get(group, [])
if not isinstance(claims, list):
raise ValueError(f"Node {group} must be an array")
for index, claim in enumerate(claims):
if not isinstance(claim, dict) or not isinstance(claim.get("text"), str):
raise ValueError(f"Node {group}[{index}] must contain claim text")
digest = assertion_digest(claim["text"])
evidence = claim.get("evidence")
if not isinstance(evidence, list) or not evidence:
raise ValueError(f"Node {group}[{index}] must contain evidence")
path = f"{group}[{index}].text"
evidence_digests = []
for item in evidence:
if not isinstance(item, dict):
raise ValueError("Evidence references must be objects")
item["assertion_path"] = path
item["assertion_digest"] = digest
parsed_evidence = EvidenceReference.model_validate(item, strict=True)
item.clear()
item.update(parsed_evidence.model_dump(mode="json"))
evidence_digests.append(evidence_digest(parsed_evidence))
assessment = claim.get("assessment")
if not isinstance(assessment, dict):
raise ValueError(f"Node {group}[{index}] must contain an assessment")
assessment["assertion_digest"] = digest
assessment["evidence_digests"] = evidence_digests
edge = operation.get("edge")
if isinstance(edge, dict):
edge_evidence = edge.get("evidence", [])
if not isinstance(edge_evidence, list):
raise ValueError("Edge evidence must be an array")
for item in edge_evidence:
if not isinstance(item, dict):
raise ValueError("Evidence references must be objects")
item["assertion_path"] = "edge"
item["assertion_digest"] = "0" * 64
parsed_edge = TypedEdge.model_validate(edge, strict=True)
digest = edge_assertion_digest(parsed_edge)
for item in edge_evidence:
item["assertion_digest"] = digest
parsed_evidence = EvidenceReference.model_validate(item, strict=True)
item.clear()
item.update(parsed_evidence.model_dump(mode="json"))
edge["evidence"] = edge_evidence
value["payload_digest"] = "0" * 64
provisional = ProposalRevision.model_validate_json(
json.dumps(value, ensure_ascii=False, allow_nan=False), strict=True
)
return provisional.model_copy(update={"payload_digest": proposal_payload_digest(provisional)})
def edge_assertion_digest(edge: TypedEdge) -> str:
"""Bind edge evidence to the relationship, conditions and rationale."""