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

+145 -6
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import hashlib
import json
import re
import unicodedata
from collections.abc import Iterable
from copy import deepcopy
@@ -51,6 +52,12 @@ def evidence_digest(evidence: EvidenceReference) -> str:
return hashlib.sha256(canonical_json(evidence.model_dump(mode="json"))).hexdigest()
def _numeric_tokens(text: str) -> set[str]:
"""Return normalized numeric literals for a conservative evidence check."""
matches = re.findall(r"(?<!\w)\d+(?:,\d{3})*(?:\.\d+)?", text)
return {match.replace(",", "") for match in matches}
def extraction_digest(extraction: ExtractionRecord) -> str:
value = extraction.model_dump(mode="json", exclude={"extraction_id"})
return hashlib.sha256(canonical_json(value)).hexdigest()
@@ -58,6 +65,18 @@ def extraction_digest(extraction: ExtractionRecord) -> str:
def proposal_payload_digest(proposal: ProposalRevision) -> str:
operations = [item.model_dump(mode="json") for item in proposal.operations]
def omit_empty_online_evidence(value: Any) -> None:
if isinstance(value, dict):
if value.get("online_evidence") == []:
value.pop("online_evidence")
for child in value.values():
omit_empty_online_evidence(child)
elif isinstance(value, list):
for child in value:
omit_empty_online_evidence(child)
omit_empty_online_evidence(operations)
return hashlib.sha256(canonical_json(operations)).hexdigest()
@@ -112,7 +131,12 @@ def prepare_proposal_digests(draft: dict[str, Any]) -> ProposalRevision:
raise ValueError("Evidence references must be objects")
item["assertion_path"] = "edge"
item["assertion_digest"] = "0" * 64
parsed_edge = TypedEdge.model_validate(edge, strict=True)
# The draft originated as JSON, where arrays represent tuple fields.
# Revalidate through the JSON path so strict mode accepts the typed
# edge evidence array without weakening Python-object validation.
parsed_edge = TypedEdge.model_validate_json(
json.dumps(edge, ensure_ascii=False, allow_nan=False), strict=True
)
digest = edge_assertion_digest(parsed_edge)
for item in edge_evidence:
item["assertion_digest"] = digest
@@ -176,6 +200,71 @@ def _entity_names(node: KnowledgeNode) -> set[str]:
}
def _validate_node_enrichment(
previous: KnowledgeNode,
updated: KnowledgeNode,
proposer_id: str,
path: str,
) -> tuple[Finding, ...]:
"""Require an update to preserve accepted knowledge and add only assessed material."""
findings: list[Finding] = []
for field in ("node_id", "project_id", "node_type", "created", "lifecycle"):
if getattr(previous, field) != getattr(updated, field):
findings.append(
_finding("NODE_ENRICHMENT_IDENTITY_CHANGED", f"Enrichment cannot change {field}", path)
)
if updated.updated <= previous.updated:
findings.append(
_finding("NODE_ENRICHMENT_TIMESTAMP_INVALID", "Enrichment must advance updated", path)
)
if updated.summary != previous.summary:
findings.append(
_finding(
"NODE_ENRICHMENT_SUMMARY_CHANGED",
"Enrichment cannot replace the summary; add supported claims instead",
f"{path}.summary",
)
)
for field in ("claims", "assumptions", "limitations"):
old_items = {claim.claim_id: claim for claim in getattr(previous, field)}
new_items = {claim.claim_id: claim for claim in getattr(updated, field)}
if not old_items.keys() <= new_items.keys():
findings.append(
_finding("NODE_ENRICHMENT_REMOVED_CLAIM", "Enrichment cannot remove accepted claims", f"{path}.{field}")
)
continue
for claim_id, old_claim in old_items.items():
new_claim = new_items[claim_id]
claim_path = f"{path}.{field}.{claim_id}"
if (
old_claim.text != new_claim.text
or old_claim.evidence_type != new_claim.evidence_type
):
findings.append(
_finding("NODE_ENRICHMENT_CHANGED_CLAIM", "Enrichment cannot alter an accepted assertion", claim_path)
)
continue
if not set(old_claim.online_evidence) <= set(new_claim.online_evidence):
findings.append(
_finding("NODE_ENRICHMENT_REMOVED_ONLINE_EVIDENCE", "Enrichment cannot remove prior online evidence", claim_path)
)
old_evidence = set(old_claim.evidence)
if not old_evidence <= set(new_claim.evidence):
findings.append(
_finding("NODE_ENRICHMENT_REMOVED_EVIDENCE", "Enrichment cannot remove accepted evidence", claim_path)
)
if new_claim.evidence != old_claim.evidence and new_claim.assessment.assessor_id != proposer_id:
findings.append(
_finding("ASSESSOR_ATTRIBUTION_MISMATCH", "New evidence requires an assessment by its proposer", claim_path)
)
if not set(previous.tags) <= set(updated.tags):
findings.append(_finding("NODE_ENRICHMENT_REMOVED_FACET", "Enrichment cannot remove existing tags", f"{path}.tags"))
for field in ("audience", "domain", "use_for"):
if not set(getattr(previous, field)) <= set(getattr(updated, field)):
findings.append(_finding("NODE_ENRICHMENT_REMOVED_FACET", "Enrichment cannot remove existing facets", f"{path}.{field}"))
return tuple(findings)
def _entity_visible(
candidate: KnowledgeNode,
existing: KnowledgeNode,
@@ -220,7 +309,7 @@ def validate_proposal(
proposed_nodes = {
operation.node.node_id: operation.node
for operation in proposal.operations
if operation.node is not None
if operation.node is not None and operation.operation == "upsert_node"
}
known_nodes = {**existing_nodes, **proposed_nodes}
entity_nodes_seen: list[KnowledgeNode] = []
@@ -242,7 +331,10 @@ def validate_proposal(
)
)
known_sources[source.revision_id] = source
source_domains = {"shared", *source.ingested_for}
# Source relevance is explicit: a source ingested for one project
# does not become shared knowledge merely because its record is
# stored in the canonical source registry.
source_domains = set(source.ingested_for)
for project_id in source.ingested_for:
if project_id != "shared" and project_id not in projects:
findings.append(
@@ -269,8 +361,13 @@ def validate_proposal(
if operation.node is not None:
node = operation.node
previous_node = (
existing_nodes.get(operation.target_id or "")
if operation.operation == "update_node"
else None
)
eligibility_checks.append(((node.project_id,), "node", node.node_type.value))
if node.node_id in existing_nodes:
if operation.operation == "upsert_node" and node.node_id in existing_nodes:
findings.append(
_finding(
"NODE_ID_EXISTS",
@@ -278,6 +375,15 @@ def validate_proposal(
f"{path}.node.node_id",
)
)
elif operation.operation == "update_node":
if previous_node is None:
findings.append(
_finding("NODE_NOT_FOUND", "Update target is not an accepted node", f"{path}.target_id")
)
else:
findings.extend(
_validate_node_enrichment(previous_node, node, proposal.proposer_id, f"{path}.node")
)
if node.node_type.value == "entity":
possible_duplicates = [
other
@@ -325,7 +431,20 @@ def validate_proposal(
)
for group_name, claim_index, claim in claim_items:
claim_path = f"{path}.node.{group_name}[{claim_index}]"
if claim.assessment.assessor_id != proposal.proposer_id:
previous_claims = (
{item.claim_id: item for item in previous_node.evidence_claims}
if previous_node is not None
else {}
)
old_claim = previous_claims.get(claim.claim_id)
unchanged_prior_assessment = bool(
old_claim
and old_claim.text == claim.text
and old_claim.evidence_type == claim.evidence_type
and old_claim.evidence == claim.evidence
and old_claim.assessment == claim.assessment
)
if claim.assessment.assessor_id != proposal.proposer_id and not unchanged_prior_assessment:
findings.append(
_finding(
"ASSESSOR_ATTRIBUTION_MISMATCH",
@@ -362,6 +481,17 @@ def validate_proposal(
claim_path,
)
)
quoted_text = "\n".join(evidence.quote for evidence in claim.evidence)
unquoted_numbers = _numeric_tokens(claim.text) - _numeric_tokens(quoted_text)
if unquoted_numbers:
findings.append(
_finding(
"NUMERIC_CLAIM_UNGROUNDED",
"Claim contains numeric literals absent from attached evidence; "
"semantic review is required",
claim_path,
)
)
for evidence_index, evidence in enumerate(claim.evidence):
evidence_path = f"{claim_path}.evidence[{evidence_index}]"
expected_assertion_path = f"{group_name}[{claim_index}].text"
@@ -636,7 +766,7 @@ def validate_proposal(
)
)
if operation.target_id is not None:
if operation.operation == "flag_stale" and operation.target_id is not None:
target = existing_nodes.get(operation.target_id)
if target is None:
findings.append(
@@ -720,6 +850,15 @@ def validate_proposal(
"SOURCE_REVISION_CONFLICT",
"PROJECT_NOT_REGISTERED",
"NODE_ID_EXISTS",
"NODE_NOT_FOUND",
"NODE_ENRICHMENT_IDENTITY_CHANGED",
"NODE_ENRICHMENT_TIMESTAMP_INVALID",
"NODE_ENRICHMENT_SUMMARY_CHANGED",
"NODE_ENRICHMENT_REMOVED_CLAIM",
"NODE_ENRICHMENT_CHANGED_CLAIM",
"NODE_ENRICHMENT_REMOVED_ONLINE_EVIDENCE",
"NODE_ENRICHMENT_REMOVED_EVIDENCE",
"NODE_ENRICHMENT_REMOVED_FACET",
"ASSESSMENT_STALE",
"ASSESSMENT_EVIDENCE_MISMATCH",
"SUPPORT_UNAVAILABLE",