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
|
||||
|
||||
@@ -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})),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user