CI / quality (push) Canceled after 0s
Wraps over-long lines, sorts imports, drops an unused local, and bumps the agent submodule for its own import-sort fix so make check passes.
1300 lines
48 KiB
Python
1300 lines
48 KiB
Python
"""Versioned public records shared by GEWU transports and adapters."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import re
|
|
import unicodedata
|
|
from datetime import date, datetime
|
|
from enum import StrEnum
|
|
from typing import Annotated, Literal
|
|
from urllib.parse import urlsplit
|
|
|
|
from pydantic import (
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
FiniteFloat,
|
|
StringConstraints,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
SCHEMA_VERSION = 1
|
|
ResearchLens = Literal[
|
|
"technical",
|
|
"conceptual",
|
|
"cultural-historical",
|
|
"financial-market",
|
|
]
|
|
FacetName = Literal["tags", "audience", "use_for", "domain"]
|
|
FacetLabelStatus = Literal["active", "merged", "retired"]
|
|
DEFAULT_RESEARCH_LENSES: tuple[ResearchLens, ...] = (
|
|
"technical",
|
|
"conceptual",
|
|
"cultural-historical",
|
|
"financial-market",
|
|
)
|
|
|
|
RecordId = Annotated[
|
|
str,
|
|
StringConstraints(
|
|
min_length=1,
|
|
max_length=128,
|
|
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
|
|
),
|
|
]
|
|
SearchFacetValue = Annotated[
|
|
str,
|
|
StringConstraints(min_length=1, max_length=100),
|
|
]
|
|
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."""
|
|
|
|
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
|
|
schema_version: Literal[1] = 1
|
|
|
|
|
|
class ProjectStatus(StrEnum):
|
|
ACTIVE = "active"
|
|
PAUSED = "paused"
|
|
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)
|
|
status: ProjectStatus
|
|
description: str = Field(default="", max_length=4000)
|
|
owners: tuple[RecordId, ...] = ()
|
|
north_star: str = Field(min_length=1, max_length=4000)
|
|
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
|
|
research_lenses: tuple[ResearchLens, ...] = DEFAULT_RESEARCH_LENSES
|
|
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")
|
|
@classmethod
|
|
def project_id_is_slug(cls, value: str) -> str:
|
|
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value):
|
|
raise ValueError("Project IDs must be lowercase hyphen-separated slugs")
|
|
return value
|
|
|
|
|
|
class IngestGuidance(StrictRecord):
|
|
guidance_id: RecordId
|
|
lens: ResearchLens
|
|
rule: str = Field(min_length=1, max_length=1000)
|
|
created_at: datetime
|
|
|
|
@field_validator("created_at")
|
|
@classmethod
|
|
def guidance_timestamp_is_utc(cls, value: datetime) -> datetime:
|
|
offset = value.utcoffset()
|
|
if offset is None or offset.total_seconds() != 0:
|
|
raise ValueError("guidance timestamp must be timezone-aware UTC")
|
|
return value
|
|
|
|
|
|
class ProjectLens(StrictRecord):
|
|
project_id: RecordId
|
|
title: str = Field(min_length=1, max_length=200)
|
|
status: ProjectStatus
|
|
north_star: str = Field(min_length=1, max_length=4000)
|
|
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
|
|
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)
|
|
|
|
|
|
class ProjectContextResponse(StrictRecord):
|
|
lens: ProjectLens
|
|
|
|
|
|
class JevFacetScore(StrictRecord):
|
|
facet: FacetName
|
|
value: SearchFacetValue
|
|
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
|
|
run_id: RecordId
|
|
source_revision_id: RecordId
|
|
extraction_id: Sha256
|
|
locator: Locator
|
|
excerpt: str = Field(min_length=1, max_length=4000)
|
|
lens: ResearchLens
|
|
summary: str = Field(min_length=1, max_length=2000)
|
|
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)
|
|
jev_model: str | None = Field(default=None, max_length=100)
|
|
status: Literal["needs-user-review", "included", "excluded", "deferred"] = "needs-user-review"
|
|
submitted_by: RecordId
|
|
created_at: datetime
|
|
|
|
@field_validator("created_at")
|
|
@classmethod
|
|
def review_timestamp_is_utc(cls, value: datetime) -> datetime:
|
|
offset = value.utcoffset()
|
|
if offset is None or offset.total_seconds() != 0:
|
|
raise ValueError("review timestamp must be timezone-aware UTC")
|
|
return value
|
|
|
|
|
|
class IngestReviewCreate(StrictRecord):
|
|
review_id: RecordId
|
|
project_id: RecordId
|
|
run_id: RecordId
|
|
source_revision_id: RecordId
|
|
extraction_id: Sha256
|
|
locator: Locator
|
|
excerpt: str = Field(min_length=1, max_length=4000)
|
|
lens: ResearchLens
|
|
summary: str = Field(min_length=1, max_length=2000)
|
|
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=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:
|
|
values = (
|
|
*self.candidate_tags,
|
|
*self.candidate_audience,
|
|
*self.candidate_use_for,
|
|
*self.candidate_domain,
|
|
)
|
|
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),
|
|
):
|
|
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
|
|
review_id: RecordId
|
|
decision: Literal["include", "exclude", "defer"]
|
|
rationale: str = Field(min_length=1, max_length=2000)
|
|
save_as_guidance: bool = False
|
|
guidance_rule: str | None = Field(default=None, max_length=1000)
|
|
|
|
@model_validator(mode="after")
|
|
def guidance_requires_explicit_rule(self) -> IngestReviewDecision:
|
|
if self.save_as_guidance and not (self.guidance_rule or "").strip():
|
|
raise ValueError("saving project guidance requires a non-empty guidance rule")
|
|
if not self.save_as_guidance and self.guidance_rule is not None:
|
|
raise ValueError("guidance_rule requires explicit save_as_guidance")
|
|
return self
|
|
|
|
|
|
class ReliabilityGrade(StrEnum):
|
|
LOW = "low"
|
|
MEDIUM = "medium"
|
|
HIGH = "high"
|
|
|
|
|
|
class AutoAcceptPolicy(StrictRecord):
|
|
policy_id: RecordId
|
|
policy_version: int = Field(ge=1)
|
|
project_id: RecordId
|
|
automatic_node_types: tuple[
|
|
Literal[
|
|
"finding",
|
|
"problem",
|
|
"technique",
|
|
"case",
|
|
"entity",
|
|
"event",
|
|
"concept",
|
|
"datapoint",
|
|
"synthesis",
|
|
],
|
|
...,
|
|
] = ()
|
|
automatic_edge_types: tuple[
|
|
Literal[
|
|
"solves",
|
|
"addresses",
|
|
"causes",
|
|
"supports",
|
|
"contradicts",
|
|
"instance_of",
|
|
"part_of",
|
|
"precedes",
|
|
"supersedes",
|
|
"compared_with",
|
|
"derived_from",
|
|
"applies_to",
|
|
],
|
|
...,
|
|
] = ()
|
|
automatic_source_registration: bool = False
|
|
require_supported_assessment: bool = True
|
|
require_owner_decision_for: tuple[
|
|
Literal["delete", "merge", "promote", "intent", "policy"], ...
|
|
] = (
|
|
"delete",
|
|
"merge",
|
|
"promote",
|
|
"intent",
|
|
"policy",
|
|
)
|
|
|
|
|
|
class SourceRevisionRecord(StrictRecord):
|
|
source_id: RecordId
|
|
revision_id: RecordId
|
|
artifact_sha256: Sha256 | None = None
|
|
media_type: str = Field(min_length=1, max_length=128)
|
|
byte_length: int = Field(ge=0)
|
|
captured_at: datetime
|
|
capture_method: Literal[
|
|
"unspecified", "local-file", "url-fetch", "pasted-text", "notebooklm-export", "transcript"
|
|
] = "unspecified"
|
|
original_url: str | None = Field(default=None, max_length=4000)
|
|
final_url: str | None = Field(default=None, max_length=4000)
|
|
title: str = Field(min_length=1, max_length=500)
|
|
authors: tuple[str, ...] = Field(default=(), max_length=50)
|
|
publisher: str | None = Field(default=None, max_length=300)
|
|
publication_date: datetime | None = None
|
|
license: str = Field(min_length=1, max_length=500)
|
|
source_type: Literal[
|
|
"paper", "review", "press", "vendor", "internal", "talk", "export", "other"
|
|
]
|
|
reliability_grade: ReliabilityGrade
|
|
ingested_for: tuple[RecordId, ...] = Field(min_length=1, max_length=50)
|
|
archive_status: Literal["archived", "reference-only", "capture-failed"]
|
|
|
|
@field_validator("captured_at", "publication_date")
|
|
@classmethod
|
|
def timestamps_are_utc(cls, value: datetime | None) -> datetime | None:
|
|
offset = value.utcoffset() if value is not None and value.tzinfo is not None else None
|
|
if value is not None and (offset is None or offset.total_seconds() != 0):
|
|
raise ValueError("timestamps must be timezone-aware UTC")
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def archive_metadata_matches_artifact(self) -> SourceRevisionRecord:
|
|
if self.archive_status == "archived":
|
|
if self.artifact_sha256 is None or self.byte_length < 1:
|
|
raise ValueError("archived sources require a non-empty archived artifact")
|
|
elif self.artifact_sha256 is not None or self.byte_length != 0:
|
|
raise ValueError("unarchived sources cannot claim artifact bytes or a digest")
|
|
if self.archive_status != "archived" and not self.original_url:
|
|
raise ValueError("unarchived source records require an original URL")
|
|
return self
|
|
|
|
|
|
class ExtractionBlock(StrictRecord):
|
|
block_id: RecordId
|
|
ordinal: int = Field(ge=0)
|
|
original_text: str = Field(max_length=100_000)
|
|
normalized_text: str = Field(max_length=100_000)
|
|
normalization_map: tuple[tuple[int, int], ...] = Field(max_length=100_000)
|
|
page: int | None = Field(default=None, ge=1)
|
|
time_start_ms: int | None = Field(default=None, ge=0)
|
|
time_end_ms: int | None = Field(default=None, ge=0)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_normalization_map(self) -> ExtractionBlock:
|
|
if len(self.normalization_map) != len(self.normalized_text):
|
|
raise ValueError("normalization map must cover every normalized code point")
|
|
if (self.time_start_ms is None) != (self.time_end_ms is None):
|
|
raise ValueError("transcript cue start and end times must be provided together")
|
|
if self.time_start_ms is not None and self.time_end_ms is not None:
|
|
if self.time_end_ms <= self.time_start_ms:
|
|
raise ValueError("transcript cue end must follow its start")
|
|
previous_start = -1
|
|
for start, end in self.normalization_map:
|
|
if start < previous_start or end <= start or end > len(self.original_text):
|
|
raise ValueError("normalization map contains an invalid original span")
|
|
previous_start = start
|
|
return self
|
|
|
|
|
|
class ExtractionRecord(StrictRecord):
|
|
extraction_id: Sha256
|
|
source_revision_id: RecordId
|
|
parser_name: str = Field(min_length=1, max_length=100)
|
|
parser_version: str = Field(min_length=1, max_length=100)
|
|
parser_config_sha256: Sha256
|
|
normalization_profile: Literal["unicode-nfc-lf-v1"]
|
|
status: Literal["complete", "partial", "unavailable"]
|
|
blocks: tuple[ExtractionBlock, ...] = Field(max_length=100_000)
|
|
warnings: tuple[str, ...] = ()
|
|
|
|
|
|
class OnlineEvidence(StrictRecord):
|
|
"""A short online excerpt with its relationship to a claim and verification URL."""
|
|
|
|
url: str = Field(min_length=1, max_length=4000)
|
|
text: str = Field(min_length=1, max_length=8000)
|
|
relation: Literal["supports", "contradicts", "context", "inconclusive"]
|
|
context: str = Field(min_length=1, max_length=4000)
|
|
|
|
@field_validator("url")
|
|
@classmethod
|
|
def public_http_url(cls, value: str) -> str:
|
|
parsed = urlsplit(value)
|
|
hostname = parsed.hostname
|
|
if (
|
|
parsed.scheme not in {"http", "https"}
|
|
or not hostname
|
|
or parsed.username
|
|
or parsed.password
|
|
):
|
|
raise ValueError("online evidence requires an HTTP(S) URL without embedded credentials")
|
|
if hostname.lower() == "localhost" or hostname.lower().endswith((".localhost", ".local")):
|
|
raise ValueError("web references must use a public hostname")
|
|
try:
|
|
address = ipaddress.ip_address(hostname)
|
|
except ValueError:
|
|
address = None
|
|
if address is not None and not address.is_global:
|
|
raise ValueError("web references must use a public hostname")
|
|
return value
|
|
|
|
class Claim(StrictRecord):
|
|
claim_id: RecordId
|
|
text: str = Field(min_length=1, max_length=4000)
|
|
evidence_type: Literal["measured", "reported", "opinion", "vendor_marketing"]
|
|
evidence: tuple[EvidenceReference, ...] = Field(min_length=1, max_length=20)
|
|
assessment: SupportAssessment
|
|
online_evidence: tuple[OnlineEvidence, ...] = Field(default=(), max_length=20)
|
|
|
|
@model_validator(mode="after")
|
|
def assessment_matches_evidence(self) -> Claim:
|
|
expected_digests = {item.assertion_digest for item in self.evidence}
|
|
if expected_digests != {self.assessment.assertion_digest}:
|
|
raise ValueError("support assessment must bind the claim's exact assertion digest")
|
|
return self
|
|
|
|
|
|
class NodeType(StrEnum):
|
|
FINDING = "finding"
|
|
PROBLEM = "problem"
|
|
TECHNIQUE = "technique"
|
|
CASE = "case"
|
|
ENTITY = "entity"
|
|
EVENT = "event"
|
|
CONCEPT = "concept"
|
|
DATAPOINT = "datapoint"
|
|
SYNTHESIS = "synthesis"
|
|
|
|
|
|
class ReviewState(StrEnum):
|
|
DRAFT = "draft"
|
|
REVIEWED = "reviewed"
|
|
|
|
|
|
class Lifecycle(StrEnum):
|
|
ACTIVE = "active"
|
|
SUPERSEDED = "superseded"
|
|
|
|
|
|
class Confidence(StrEnum):
|
|
LOW = "low"
|
|
MEDIUM = "medium"
|
|
HIGH = "high"
|
|
|
|
|
|
class LensFit(StrictRecord):
|
|
score: FiniteFloat = Field(ge=0, le=1)
|
|
horizon: Literal["H1", "H2", "H3"]
|
|
key_question: str | None = Field(default=None, max_length=1000)
|
|
reason: str = Field(min_length=1, max_length=1000)
|
|
|
|
|
|
class KnowledgeNode(StrictRecord):
|
|
node_id: RecordId
|
|
project_id: RecordId
|
|
node_type: NodeType
|
|
title: str = Field(min_length=1, max_length=300)
|
|
summary: str = Field(min_length=1, max_length=8000)
|
|
created: datetime
|
|
updated: datetime
|
|
review_state: ReviewState
|
|
lifecycle: Lifecycle
|
|
confidence: Confidence
|
|
claim_support: Literal["pending", "supported", "unsupported"]
|
|
assumptions: tuple[Claim, ...] = Field(default=(), max_length=100)
|
|
limitations: tuple[Claim, ...] = Field(default=(), max_length=100)
|
|
tags: tuple[str, ...] = Field(default=(), max_length=100)
|
|
audience: tuple[str, ...] = Field(default=(), max_length=20)
|
|
domain: tuple[str, ...] = Field(default=(), max_length=20)
|
|
use_for: tuple[str, ...] = Field(default=(), max_length=20)
|
|
lens_fit: dict[RecordId, LensFit] = Field(default_factory=dict, max_length=50)
|
|
run_id: RecordId
|
|
agent_id: RecordId
|
|
model_id: str = Field(min_length=1, max_length=200)
|
|
claims: tuple[Claim, ...] = Field(min_length=1, max_length=100)
|
|
as_of: datetime | None = None
|
|
stale_after: datetime | None = None
|
|
supersedes: tuple[RecordId, ...] = ()
|
|
superseded_by: tuple[RecordId, ...] = ()
|
|
aliases: tuple[str, ...] = Field(default=(), max_length=100)
|
|
merged_from: tuple[RecordId, ...] = Field(default=(), max_length=100)
|
|
value: FiniteFloat | None = None
|
|
unit: str | None = Field(default=None, max_length=100)
|
|
sample_size: int | None = Field(default=None, ge=0)
|
|
reporting_year: int | None = Field(default=None, ge=1, le=9999)
|
|
demographic: str | None = Field(default=None, max_length=1000)
|
|
|
|
@property
|
|
def evidence_claims(self) -> tuple[Claim, ...]:
|
|
"""All factual statements that carry citation and assessment requirements."""
|
|
|
|
return (*self.claims, *self.assumptions, *self.limitations)
|
|
|
|
@field_validator("aliases")
|
|
@classmethod
|
|
def normalize_aliases(cls, values: tuple[str, ...]) -> tuple[str, ...]:
|
|
return tuple(" ".join(unicodedata.normalize("NFC", value).split()) for value in values)
|
|
|
|
@model_validator(mode="after")
|
|
def datapoint_has_freshness(self) -> KnowledgeNode:
|
|
claim_ids = [claim.claim_id for claim in self.evidence_claims]
|
|
if len(claim_ids) != len(set(claim_ids)):
|
|
raise ValueError("claim IDs must be unique across claims, assumptions and limitations")
|
|
normalized_aliases = [" ".join(alias.split()).casefold() for alias in self.aliases]
|
|
if any(not alias for alias in normalized_aliases):
|
|
raise ValueError("aliases cannot be empty")
|
|
if len(normalized_aliases) != len(set(normalized_aliases)):
|
|
raise ValueError("aliases must be unique ignoring case and whitespace")
|
|
normalized_title = " ".join(self.title.split()).casefold()
|
|
if normalized_title in normalized_aliases:
|
|
raise ValueError("aliases cannot repeat the node title")
|
|
if self.node_type == NodeType.DATAPOINT and (
|
|
self.as_of is None
|
|
or self.stale_after is None
|
|
or self.value is None
|
|
or self.unit is None
|
|
):
|
|
raise ValueError("datapoints require as_of, stale_after, value and unit")
|
|
if self.as_of and self.stale_after and self.stale_after <= self.as_of:
|
|
raise ValueError("stale_after must be later than as_of")
|
|
if self.updated < self.created:
|
|
raise ValueError("updated must not precede created")
|
|
if self.claim_support != "supported":
|
|
raise ValueError("accepted factual nodes must carry supported claim status")
|
|
if self.lifecycle == Lifecycle.SUPERSEDED and not self.superseded_by:
|
|
raise ValueError("superseded nodes require a superseded_by target")
|
|
return self
|
|
|
|
@field_validator("created", "updated", "as_of", "stale_after")
|
|
@classmethod
|
|
def node_timestamps_are_utc(cls, value: datetime | None) -> datetime | None:
|
|
offset = value.utcoffset() if value is not None and value.tzinfo is not None else None
|
|
if value is not None and (offset is None or offset.total_seconds() != 0):
|
|
raise ValueError("timestamps must be timezone-aware UTC")
|
|
return value
|
|
|
|
|
|
class EdgeEndpoint(StrictRecord):
|
|
kind: Literal["node", "source", "project"]
|
|
id: RecordId
|
|
|
|
|
|
class TypedEdge(StrictRecord):
|
|
edge_id: RecordId
|
|
edge_type: Literal[
|
|
"solves",
|
|
"addresses",
|
|
"causes",
|
|
"supports",
|
|
"contradicts",
|
|
"instance_of",
|
|
"part_of",
|
|
"precedes",
|
|
"supersedes",
|
|
"compared_with",
|
|
"derived_from",
|
|
"applies_to",
|
|
]
|
|
source: EdgeEndpoint
|
|
target: EdgeEndpoint
|
|
conditions: str | None = Field(default=None, max_length=2000)
|
|
evidence: tuple[EvidenceReference, ...] = ()
|
|
interpretation_rationale: str | None = Field(default=None, max_length=4000)
|
|
|
|
@model_validator(mode="after")
|
|
def substantive_edge_has_basis(self) -> TypedEdge:
|
|
if self.edge_type == "derived_from" and (
|
|
self.source.kind != "node" or self.target.kind != "source"
|
|
):
|
|
raise ValueError("derived_from connects a knowledge node to a source revision")
|
|
if self.edge_type == "applies_to" and (
|
|
self.source.kind != "node" or self.target.kind != "project"
|
|
):
|
|
raise ValueError("applies_to connects a knowledge node to a project")
|
|
if self.edge_type not in {"derived_from", "applies_to"} and (
|
|
self.source.kind != "node" or self.target.kind != "node"
|
|
):
|
|
raise ValueError("this edge type connects two knowledge nodes")
|
|
if self.edge_type in {"solves", "addresses", "causes", "supports", "contradicts"}:
|
|
if not self.evidence and not self.interpretation_rationale:
|
|
raise ValueError(
|
|
"substantive edges require evidence or labeled interpretive rationale"
|
|
)
|
|
return self
|
|
|
|
|
|
class ProposalOperation(StrictRecord):
|
|
operation: Literal[
|
|
"register_source", "upsert_node", "update_node", "add_edge", "flag_stale"
|
|
]
|
|
source: SourceRevisionRecord | None = None
|
|
node: KnowledgeNode | None = None
|
|
edge: TypedEdge | None = None
|
|
target_id: RecordId | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def payload_matches_operation(self) -> ProposalOperation:
|
|
payloads = {
|
|
"register_source": self.source,
|
|
"upsert_node": self.node,
|
|
"update_node": self.node,
|
|
"add_edge": self.edge,
|
|
}
|
|
expected = payloads.get(self.operation)
|
|
supplied = sum(value is not None for value in (self.source, self.node, self.edge))
|
|
if self.operation == "flag_stale":
|
|
if supplied or self.target_id is None:
|
|
raise ValueError(
|
|
"flag_stale uses an explicit target elsewhere, not a record payload"
|
|
)
|
|
elif supplied != 1 or expected is None:
|
|
raise ValueError("operation must carry exactly its matching typed payload")
|
|
elif self.operation == "update_node":
|
|
if self.target_id is None or self.node is None or self.target_id != self.node.node_id:
|
|
raise ValueError("update_node target_id must match the stable node ID")
|
|
elif self.target_id is not None:
|
|
raise ValueError("target_id is only valid for update_node or flag_stale")
|
|
return self
|
|
|
|
|
|
class ProposalRevision(StrictRecord):
|
|
proposal_id: RecordId
|
|
revision: int = Field(ge=1)
|
|
initiating_project_id: RecordId
|
|
scope: Scope
|
|
base_commit: GitCommitId
|
|
payload_digest: Sha256
|
|
proposer_id: RecordId
|
|
run_id: RecordId
|
|
operations: tuple[ProposalOperation, ...] = Field(min_length=1, max_length=100)
|
|
|
|
|
|
class Scope(StrictRecord):
|
|
"""Project context plus optional project includes; all-project access is deliberate."""
|
|
|
|
projects: tuple[RecordId, ...] = Field(min_length=1, max_length=50)
|
|
include_projects: tuple[RecordId, ...] | None = Field(default=None, max_length=50)
|
|
include_shared: bool = True
|
|
all_projects: bool = False
|
|
|
|
@model_validator(mode="after")
|
|
def validate_all_projects(self) -> Scope:
|
|
if self.all_projects and self.projects != ("all",):
|
|
raise ValueError("all_projects requires projects=['all']")
|
|
if not self.all_projects and "all" in self.projects:
|
|
raise ValueError("projects=['all'] requires all_projects=true")
|
|
if len(set(self.projects)) != len(self.projects):
|
|
raise ValueError("scope projects must be unique")
|
|
if self.include_projects is not None:
|
|
if len(set(self.include_projects)) != len(self.include_projects):
|
|
raise ValueError("scope includes must be unique")
|
|
if set(self.projects) & set(self.include_projects):
|
|
raise ValueError("scope includes cannot repeat project context")
|
|
if "shared" in self.include_projects:
|
|
raise ValueError("shared scope must use include_shared")
|
|
if self.all_projects and self.include_projects is not None:
|
|
raise ValueError("all_projects cannot be combined with project includes")
|
|
return self
|
|
|
|
|
|
class SearchRequest(StrictRecord):
|
|
query: str = Field(default="", max_length=1000)
|
|
scope: Scope
|
|
node_types: tuple[NodeType, ...] = Field(default=(), max_length=9)
|
|
tags: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
confidences: tuple[Confidence, ...] = Field(default=(), max_length=3)
|
|
as_of_from: date | None = None
|
|
as_of_to: date | None = None
|
|
stale_only: bool = False
|
|
limit: int = Field(default=20, ge=1, le=50)
|
|
cursor: str | None = Field(default=None, max_length=512)
|
|
budget_tokens: int = Field(ge=256, le=50_000)
|
|
include_evidence: bool = True
|
|
|
|
@field_validator("query")
|
|
@classmethod
|
|
def normalize_query(cls, value: str) -> str:
|
|
return value.strip()
|
|
|
|
@model_validator(mode="after")
|
|
def require_query_or_type_filter(self) -> SearchRequest:
|
|
if not self.query and not self.node_types:
|
|
raise ValueError("search requires text or at least one knowledge type")
|
|
if self.as_of_from is not None and self.as_of_to is not None:
|
|
if self.as_of_from > self.as_of_to:
|
|
raise ValueError("as_of_from must not be later than as_of_to")
|
|
return self
|
|
|
|
@field_validator("node_types")
|
|
@classmethod
|
|
def search_node_types_are_unique(cls, value: tuple[NodeType, ...]) -> tuple[NodeType, ...]:
|
|
if len(value) != len(set(value)):
|
|
raise ValueError("search node types must be unique")
|
|
return value
|
|
|
|
@field_validator("confidences")
|
|
@classmethod
|
|
def search_confidences_are_unique(cls, value: tuple[Confidence, ...]) -> tuple[Confidence, ...]:
|
|
if len(value) != len(set(value)):
|
|
raise ValueError("search confidences must be unique")
|
|
return value
|
|
|
|
@field_validator("tags", "audience", "use_for", "domain")
|
|
@classmethod
|
|
def search_facet_values_are_unique_and_trimmed(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
if any(item != item.strip() for item in value):
|
|
raise ValueError("search facet values cannot have leading or trailing whitespace")
|
|
if len(value) != len(set(value)):
|
|
raise ValueError("search facet values must be unique")
|
|
return value
|
|
|
|
|
|
class KnowledgeBrowseRequest(StrictRecord):
|
|
"""Deterministic, paged project browsing independent of retrieval search."""
|
|
|
|
scope: Scope
|
|
query: str = Field(default="", max_length=1000)
|
|
node_types: tuple[NodeType, ...] = Field(default=(), max_length=9)
|
|
tags: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20)
|
|
confidences: tuple[Confidence, ...] = Field(default=(), max_length=3)
|
|
as_of_from: date | None = None
|
|
as_of_to: date | None = None
|
|
stale_only: bool = False
|
|
order: Literal[
|
|
"title_asc", "title_desc", "date_newest", "date_oldest", "relevance"
|
|
] = "title_asc"
|
|
limit: int = Field(default=20, ge=1, le=50)
|
|
cursor: str | None = Field(default=None, max_length=512)
|
|
budget_tokens: int = Field(ge=256, le=50_000)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_browse(self) -> KnowledgeBrowseRequest:
|
|
if (
|
|
self.as_of_from is not None
|
|
and self.as_of_to is not None
|
|
and self.as_of_from > self.as_of_to
|
|
):
|
|
raise ValueError("as_of_from must not be later than as_of_to")
|
|
if self.order == "relevance" and not self.query:
|
|
raise ValueError("relevance ordering requires a search query")
|
|
return self
|
|
|
|
@field_validator("node_types")
|
|
@classmethod
|
|
def browse_node_types_are_unique(cls, value: tuple[NodeType, ...]) -> tuple[NodeType, ...]:
|
|
if len(value) != len(set(value)):
|
|
raise ValueError("browse node types must be unique")
|
|
return value
|
|
|
|
@field_validator("confidences")
|
|
@classmethod
|
|
def browse_confidences_are_unique(cls, value: tuple[Confidence, ...]) -> tuple[Confidence, ...]:
|
|
if len(value) != len(set(value)):
|
|
raise ValueError("browse confidences must be unique")
|
|
return value
|
|
|
|
@field_validator("tags", "audience", "use_for", "domain")
|
|
@classmethod
|
|
def browse_facets_are_unique_and_trimmed(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
if any(item != item.strip() for item in value) or len(value) != len(set(value)):
|
|
raise ValueError("browse facet values must be unique and trimmed")
|
|
return value
|
|
|
|
|
|
class KnowledgeBrowseEnvelope(StrictRecord):
|
|
"""A bounded browse page with scope-safe project metadata."""
|
|
|
|
api_version: Literal["v1"] = "v1"
|
|
request_id: RecordId
|
|
resolved_scope: Scope
|
|
nodes: tuple[KnowledgeNode, ...]
|
|
project_node_count: int = Field(ge=0)
|
|
project_source_count: int = Field(ge=0)
|
|
shared_node_count: int = Field(ge=0)
|
|
shared_source_count: int = Field(ge=0)
|
|
filtered_node_count: int = Field(ge=0)
|
|
facets: dict[str, dict[str, int]]
|
|
canonical_revision: GitCommitId | None
|
|
index_revision: GitCommitId | None
|
|
warnings: tuple[Finding, ...] = ()
|
|
next_cursor: str | None = None
|
|
|
|
|
|
class KnowledgeGraphRequest(StrictRecord):
|
|
scope: Scope
|
|
max_nodes: int = Field(default=200, ge=1, le=200)
|
|
max_edges: int = Field(default=500, ge=1, le=500)
|
|
center_node_id: RecordId | None = None
|
|
|
|
|
|
class KnowledgeGraphEnvelope(StrictRecord):
|
|
api_version: Literal["v1"] = "v1"
|
|
request_id: RecordId
|
|
resolved_scope: Scope
|
|
nodes: tuple[KnowledgeNode, ...]
|
|
edges: tuple[TypedEdge, ...]
|
|
truncated: bool
|
|
center_node_id: RecordId | None = None
|
|
canonical_revision: GitCommitId | None
|
|
index_revision: GitCommitId | None
|
|
warnings: tuple[Finding, ...] = ()
|
|
|
|
class SourceRelatedNode(StrictRecord):
|
|
node: KnowledgeNode
|
|
relation_basis: Literal["citation", "edge", "both"]
|
|
|
|
|
|
class SourceRelatedNodesEnvelope(StrictRecord):
|
|
api_version: Literal["v1"] = "v1"
|
|
request_id: RecordId
|
|
resolved_scope: Scope
|
|
source_revision_id: RecordId
|
|
nodes: tuple[SourceRelatedNode, ...]
|
|
total: int = Field(ge=0)
|
|
canonical_revision: GitCommitId | None
|
|
index_revision: GitCommitId | None
|
|
warnings: tuple[Finding, ...] = ()
|
|
next_cursor: str | None = None
|
|
|
|
|
|
class NeighborRequest(StrictRecord):
|
|
node_id: RecordId
|
|
scope: Scope
|
|
edge_types: tuple[
|
|
Literal[
|
|
"solves",
|
|
"addresses",
|
|
"causes",
|
|
"supports",
|
|
"contradicts",
|
|
"instance_of",
|
|
"part_of",
|
|
"precedes",
|
|
"supersedes",
|
|
"compared_with",
|
|
"derived_from",
|
|
"applies_to",
|
|
],
|
|
...,
|
|
] = Field(default=(), max_length=12)
|
|
limit: int = Field(default=20, ge=1, le=50)
|
|
cursor: str | None = Field(default=None, max_length=512)
|
|
budget_tokens: int = Field(ge=256, le=50_000)
|
|
|
|
|
|
class SourceListRequest(StrictRecord):
|
|
scope: Scope
|
|
limit: int = Field(default=20, ge=1, le=50)
|
|
cursor: str | None = Field(default=None, max_length=512)
|
|
budget_tokens: int = Field(ge=256, le=50_000)
|
|
|
|
|
|
class Locator(StrictRecord):
|
|
kind: Literal["text_span", "pdf_page_span", "transcript_timestamp_span"]
|
|
block_id: RecordId
|
|
start: int = Field(ge=0)
|
|
end: int = Field(gt=0)
|
|
page: int | None = Field(default=None, ge=1)
|
|
time_start_ms: int | None = Field(default=None, ge=0)
|
|
time_end_ms: int | None = Field(default=None, ge=0)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_span(self) -> Locator:
|
|
if self.end <= self.start:
|
|
raise ValueError("locator end must be greater than start")
|
|
if self.kind == "pdf_page_span" and self.page is None:
|
|
raise ValueError("PDF locators require a one-based page")
|
|
if self.kind != "pdf_page_span" and self.page is not None:
|
|
raise ValueError("only PDF locators can specify a page")
|
|
if self.kind == "transcript_timestamp_span":
|
|
if self.time_start_ms is None or self.time_end_ms is None:
|
|
raise ValueError("transcript locators require cue start and end times")
|
|
if self.time_end_ms <= self.time_start_ms:
|
|
raise ValueError("transcript locator end must follow its start")
|
|
elif self.time_start_ms is not None or self.time_end_ms is not None:
|
|
raise ValueError("only transcript locators can specify cue times")
|
|
return self
|
|
|
|
|
|
class EvidenceReference(StrictRecord):
|
|
source_revision_id: RecordId
|
|
extraction_id: Sha256
|
|
assertion_path: str = Field(min_length=1, max_length=256)
|
|
assertion_digest: Sha256
|
|
quote: str = Field(min_length=1, max_length=8000)
|
|
locator: Locator
|
|
normalization_profile: Literal["unicode-nfc-lf-v1"]
|
|
|
|
|
|
class SupportStatus(StrEnum):
|
|
SUPPORTED = "supported"
|
|
UNSUPPORTED = "unsupported"
|
|
UNAVAILABLE = "unavailable"
|
|
|
|
|
|
class SupportAssessment(StrictRecord):
|
|
assessment_id: RecordId
|
|
assertion_digest: Sha256
|
|
evidence_digests: tuple[Sha256, ...] = Field(min_length=1, max_length=20)
|
|
assessor_id: RecordId
|
|
run_id: RecordId
|
|
method: str = Field(min_length=1, max_length=100)
|
|
status: SupportStatus
|
|
rationale: str = Field(min_length=1, max_length=4000)
|
|
|
|
|
|
class ProposalState(StrEnum):
|
|
STAGED = "staged"
|
|
VALIDATING = "validating"
|
|
NEEDS_REVIEW = "needs-review"
|
|
ACCEPTED = "accepted"
|
|
REJECTED = "rejected"
|
|
WITHDRAWN = "withdrawn"
|
|
|
|
|
|
class Finding(StrictRecord):
|
|
code: RecordId
|
|
message: str = Field(min_length=1, max_length=1000)
|
|
path: str | None = Field(default=None, max_length=256)
|
|
|
|
|
|
class ProposalReceipt(StrictRecord):
|
|
proposal_id: RecordId
|
|
revision: int = Field(ge=1)
|
|
payload_digest: Sha256
|
|
state: ProposalState
|
|
outcome: Literal["accepted", "held", "rejected", "validation-failed"]
|
|
commit_id: GitCommitId | None = None
|
|
accepted_node_ids: tuple[RecordId, ...] = ()
|
|
findings: tuple[Finding, ...] = ()
|
|
decided_at: datetime | None = None
|
|
|
|
|
|
class ProposalStatus(StrictRecord):
|
|
proposal_id: RecordId
|
|
revision: int = Field(ge=1)
|
|
lifecycle: Literal[
|
|
"staged",
|
|
"evaluated",
|
|
"publishing",
|
|
"published-unreconciled",
|
|
"accepted",
|
|
"withdrawn",
|
|
]
|
|
validation_status: Literal["eligible", "needs-review", "invalid", "conflict"] | None = None
|
|
eligible_for_auto_accept: bool | None = None
|
|
findings: tuple[Finding, ...] = Field(default=(), max_length=200)
|
|
receipt: ProposalReceipt | None = None
|
|
canonical_revision: GitCommitId | None = None
|
|
|
|
|
|
class RunModulePin(StrictRecord):
|
|
module_id: RecordId
|
|
implementation_version: str = Field(min_length=1, max_length=64)
|
|
sha256: Sha256
|
|
|
|
|
|
class RunCoverageItem(StrictRecord):
|
|
item: str = Field(min_length=1, max_length=500)
|
|
reason: str = Field(min_length=1, max_length=1000)
|
|
|
|
|
|
class RunCoverage(StrictRecord):
|
|
selected: tuple[str, ...] = Field(default=(), max_length=10_000)
|
|
included: tuple[str, ...] = Field(default=(), max_length=10_000)
|
|
excluded: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000)
|
|
skipped: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000)
|
|
|
|
|
|
class RunSourceReference(StrictRecord):
|
|
source_revision_id: RecordId
|
|
artifact_sha256: Sha256 | None = None
|
|
extraction_id: Sha256 | None = None
|
|
status: Literal["selected", "captured", "extracted", "unavailable", "failed"]
|
|
|
|
|
|
class RunOperationReference(StrictRecord):
|
|
operation_id: RecordId
|
|
tool_name: RecordId
|
|
input_sha256: Sha256
|
|
status: Literal["pending", "uncertain", "complete", "failed"]
|
|
|
|
|
|
class RunProposalReference(StrictRecord):
|
|
proposal_id: RecordId
|
|
revision: int = Field(ge=1)
|
|
status: Literal[
|
|
"staged",
|
|
"evaluated",
|
|
"eligible",
|
|
"needs-review",
|
|
"invalid",
|
|
"conflict",
|
|
"publishing",
|
|
"accepted",
|
|
"rejected",
|
|
"withdrawn",
|
|
"uncertain",
|
|
]
|
|
receipt: ProposalReceipt | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def receipt_matches_proposal(self) -> RunProposalReference:
|
|
if self.status == "accepted":
|
|
if (
|
|
self.receipt is None
|
|
or self.receipt.proposal_id != self.proposal_id
|
|
or self.receipt.revision != self.revision
|
|
or self.receipt.state != ProposalState.ACCEPTED
|
|
):
|
|
raise ValueError("accepted run proposal requires its matching service receipt")
|
|
return self
|
|
|
|
|
|
class RunState(StrEnum):
|
|
ACTIVE = "active"
|
|
COMPLETED = "completed"
|
|
BLOCKED = "blocked"
|
|
|
|
|
|
class RunCreateRequest(StrictRecord):
|
|
run_id: RecordId
|
|
objective: str = Field(min_length=1, max_length=4000)
|
|
scope: Scope
|
|
lens: str = Field(min_length=1, max_length=2000)
|
|
pack_id: RecordId
|
|
pack_version: str = Field(min_length=1, max_length=64)
|
|
pack_snapshot_digest: Sha256
|
|
module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100)
|
|
|
|
@model_validator(mode="after")
|
|
def explicit_scope_and_unique_modules(self) -> RunCreateRequest:
|
|
if self.scope.all_projects:
|
|
raise ValueError("runs require explicit project scope")
|
|
module_ids = tuple(module.module_id for module in self.module_pins)
|
|
if len(set(module_ids)) != len(module_ids):
|
|
raise ValueError("run module pins must have unique IDs")
|
|
if not self.objective.strip() or not self.lens.strip():
|
|
raise ValueError("run objective and lens must not be blank")
|
|
return self
|
|
|
|
|
|
class RunCheckpointRequest(StrictRecord):
|
|
run_id: RecordId
|
|
expected_revision: int = Field(ge=0, le=2**31 - 2)
|
|
state: RunState = RunState.ACTIVE
|
|
stage: RecordId
|
|
sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500)
|
|
coverage: RunCoverage = Field(default_factory=RunCoverage)
|
|
budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field(
|
|
default_factory=dict, max_length=100
|
|
)
|
|
pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000)
|
|
proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000)
|
|
output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000)
|
|
|
|
|
|
class RunRecord(StrictRecord):
|
|
run_id: RecordId
|
|
revision: int = Field(ge=0)
|
|
state: RunState
|
|
objective: str = Field(min_length=1, max_length=4000)
|
|
scope: Scope
|
|
lens: str = Field(min_length=1, max_length=2000)
|
|
pack_id: RecordId
|
|
pack_version: str = Field(min_length=1, max_length=64)
|
|
pack_snapshot_digest: Sha256
|
|
module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100)
|
|
stage: RecordId
|
|
sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500)
|
|
coverage: RunCoverage = Field(default_factory=RunCoverage)
|
|
budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field(
|
|
default_factory=dict, max_length=100
|
|
)
|
|
pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000)
|
|
proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000)
|
|
output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000)
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
checkpoint_digest: Sha256 | None = None
|
|
|
|
|
|
class ChangedFile(StrictRecord):
|
|
path: str = Field(min_length=1, max_length=500)
|
|
content_sha256: Sha256
|
|
|
|
|
|
class PolicyBinding(StrictRecord):
|
|
project_id: RecordId
|
|
policy_id: RecordId
|
|
policy_version: int = Field(ge=1)
|
|
|
|
|
|
class OwnerDecisionReference(StrictRecord):
|
|
challenge_id: RecordId
|
|
owner_principal_id: RecordId
|
|
owner_session_id: Sha256
|
|
decision_digest: Sha256
|
|
domains: tuple[RecordId, ...] = Field(min_length=1, max_length=100)
|
|
grant_version: int = Field(ge=1)
|
|
decision: Literal["authorize"] = "authorize"
|
|
rationale: str = Field(min_length=1, max_length=2000)
|
|
decided_at: datetime
|
|
|
|
|
|
class AcceptanceManifest(StrictRecord):
|
|
proposal_id: RecordId
|
|
proposal_revision: int = Field(ge=1)
|
|
payload_digest: Sha256
|
|
before_revision: GitCommitId
|
|
changed_files: tuple[ChangedFile, ...] = Field(min_length=1, max_length=200)
|
|
accepted_node_ids: tuple[RecordId, ...] = ()
|
|
actor_id: RecordId
|
|
credential_id: RecordId
|
|
policy_bindings: tuple[PolicyBinding, ...]
|
|
owner_decisions: tuple[OwnerDecisionReference, ...] = Field(default=(), max_length=100)
|
|
decided_at: datetime
|
|
|
|
|
|
class ErrorCode(StrEnum):
|
|
SCOPE_REQUIRED = "SCOPE_REQUIRED"
|
|
OUT_OF_SCOPE = "OUT_OF_SCOPE"
|
|
UNAUTHORIZED = "UNAUTHORIZED"
|
|
OWNER_DECISION_REQUIRED = "OWNER_DECISION_REQUIRED"
|
|
POLICY_MISSING = "POLICY_MISSING"
|
|
EVIDENCE_MISMATCH = "EVIDENCE_MISMATCH"
|
|
SUPPORT_UNAVAILABLE = "SUPPORT_UNAVAILABLE"
|
|
BASE_CONFLICT = "BASE_CONFLICT"
|
|
IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
|
|
BUDGET_TOO_SMALL = "BUDGET_TOO_SMALL"
|
|
UPLOAD_EXPIRED = "UPLOAD_EXPIRED"
|
|
BUSY = "BUSY"
|
|
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
|
|
|
|
|
|
class DomainError(StrictRecord):
|
|
code: ErrorCode
|
|
message: str = Field(min_length=1, max_length=1000)
|
|
findings: tuple[Finding, ...] = ()
|
|
retryable: bool
|
|
request_id: RecordId
|
|
|
|
|
|
class BudgetProfile(StrictRecord):
|
|
tokenizer: str = Field(min_length=1, max_length=100)
|
|
encoding: str = Field(min_length=1, max_length=100)
|
|
|
|
|
|
class ReadEnvelope(StrictRecord):
|
|
api_version: Literal["v1"] = "v1"
|
|
request_id: RecordId
|
|
resolved_scope: Scope
|
|
data: tuple[dict[str, object], ...]
|
|
filtered_count: int | None = Field(default=None, ge=0)
|
|
citations: tuple[EvidenceReference, ...] = ()
|
|
warnings: tuple[Finding, ...] = ()
|
|
omissions: tuple[Finding, ...] = ()
|
|
canonical_revision: GitCommitId | None
|
|
index_revision: GitCommitId | None
|
|
retrieval_mode: Literal["keyword", "hybrid", "canonical"]
|
|
budget_profile: BudgetProfile
|
|
tokens_used: int = Field(ge=0)
|
|
next_cursor: str | None = None
|
|
|
|
|
|
class ServiceDiagnostics(StrictRecord):
|
|
service_version: str = Field(min_length=1, max_length=64)
|
|
api_version: Literal["v1"] = "v1"
|
|
catalog_sha256: Sha256
|
|
transport: Literal["http", "mcp"]
|
|
authentication: Literal["bearer", "browser_session"]
|
|
roles: tuple[RecordId, ...] = ()
|
|
authorized_projects: tuple[RecordId, ...] = ()
|
|
selected_scope: Scope | None = None
|
|
capabilities: tuple[RecordId, ...] = ()
|
|
authorized_operations: tuple[RecordId, ...] = ()
|
|
connection_status: Literal["authenticated"] = "authenticated"
|
|
workflow_status: Literal["not_verified"] = "not_verified"
|
|
max_upload_bytes: int = Field(ge=1)
|
|
upload_chunk_bytes: int = Field(ge=1)
|
|
supported_media_types: tuple[str, ...] = Field(default=(), max_length=20)
|
|
canonical_revision: GitCommitId
|
|
index_state: Literal["current", "lagging", "not_built", "not_configured", "unavailable"]
|
|
index_revision: GitCommitId | None = None
|
|
index_schema_version: int | None = Field(default=None, ge=1)
|