feat: online verification, node removals, and knowledge browser
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:
vasily committed 2026-10-16 20:02:29 +08:00
1 parent a3156f4e08
commit 78033bb830
83 files changed
+13476 -1051

No files matched your search

+143 -2
View File
@@ -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, ...] = ()