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