feat: online verification, node removals, and knowledge browser
CI / quality (push) Canceled after 0s
CI / quality (push) Canceled after 0s
Adds claim-bound online evidence records and citation verification scripts, node removal transport/adapter, retrieval benchmark and owner-review tooling, GraphCanvas/SourceDetail knowledge browser UI, and the ingest handoff helper. Bumps the agents/gewu pack to the online-verification branch.
This commit is contained in:
1 parent
a3156f4e08
commit
78033bb830
83 files changed
+13476
-1051
No files matched your search
@@ -2,11 +2,13 @@
|
||||
|
||||
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,
|
||||
@@ -475,12 +477,38 @@ class ExtractionRecord(StrictRecord):
|
||||
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:
|
||||
@@ -660,7 +688,9 @@ class TypedEdge(StrictRecord):
|
||||
|
||||
|
||||
class ProposalOperation(StrictRecord):
|
||||
operation: Literal["register_source", "upsert_node", "add_edge", "flag_stale"]
|
||||
operation: Literal[
|
||||
"register_source", "upsert_node", "update_node", "add_edge", "flag_stale"
|
||||
]
|
||||
source: SourceRevisionRecord | None = None
|
||||
node: KnowledgeNode | None = None
|
||||
edge: TypedEdge | None = None
|
||||
@@ -671,6 +701,7 @@ class ProposalOperation(StrictRecord):
|
||||
payloads = {
|
||||
"register_source": self.source,
|
||||
"upsert_node": self.node,
|
||||
"update_node": self.node,
|
||||
"add_edge": self.edge,
|
||||
}
|
||||
expected = payloads.get(self.operation)
|
||||
@@ -682,8 +713,11 @@ class ProposalOperation(StrictRecord):
|
||||
)
|
||||
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 flag_stale")
|
||||
raise ValueError("target_id is only valid for update_node or flag_stale")
|
||||
return self
|
||||
|
||||
|
||||
@@ -742,6 +776,7 @@ class SearchRequest(StrictRecord):
|
||||
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
|
||||
@@ -781,6 +816,111 @@ class SearchRequest(StrictRecord):
|
||||
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
|
||||
@@ -1114,6 +1254,7 @@ class ReadEnvelope(StrictRecord):
|
||||
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, ...] = ()
|
||||
|
||||
Reference in new issue
Block a user