Files
graphify/graphify/validate.py
T
dschwartzi 2c7cbb6530 Fix: validate_extraction crashes on non-hashable node id / edge endpoint
validate_extraction() is documented to return a list of error strings ("empty
list means valid"), but raised TypeError: unhashable type: 'list' when a node
id -- or an edge source/target -- was a non-hashable value such as a list. This
occurs in practice when an LLM extraction subagent emits malformed JSON like
{"id": ["foo", "bar"], ...}. Crash sites: the node_ids set comprehension and
the `edge[...] not in node_ids` membership tests.

Because build_from_json() validates at its start, a single malformed node
aborted the entire build, losing an otherwise-complete extraction of a large
corpus. build_from_json() itself would also raise (G.add_node(<list>) and the
`not in node_set` test) if the validator were bypassed.

- validate.py: build node_ids during the node pass, adding only hashable ids;
  report a non-hashable id/endpoint as an error string instead of crashing.
  All existing messages and the dangling-edge checks are preserved.
- build.py: skip dict nodes with a missing/non-hashable id and edges with
  non-hashable endpoints (stderr warning). Non-dict nodes are deliberately
  left to raise so the multigraph diagnostic still observes shape errors.
- tests: 3 cases in test_validate.py and 2 in test_build.py.
2026-06-24 08:54:11 +01:00

96 lines
4.0 KiB
Python

# validate extraction JSON against the graphify schema before graph assembly
from __future__ import annotations
VALID_FILE_TYPES = {"code", "document", "paper", "image", "rationale", "concept"}
VALID_CONFIDENCES = {"EXTRACTED", "INFERRED", "AMBIGUOUS"}
REQUIRED_NODE_FIELDS = {"id", "label", "file_type", "source_file"}
REQUIRED_EDGE_FIELDS = {"source", "target", "relation", "confidence", "source_file"}
def validate_extraction(data: dict) -> list[str]:
"""
Validate an extraction JSON dict against the graphify schema.
Returns a list of error strings - empty list means valid.
"""
if not isinstance(data, dict):
return ["Extraction must be a JSON object"]
errors: list[str] = []
# Collected during the node pass so the edge pass can reuse it. Only
# hashable ids land here; a non-hashable id (e.g. a list emitted by a
# malformed LLM extraction) is reported as an error rather than crashing
# the validator on set construction.
node_ids: set = set()
# Nodes
if "nodes" not in data:
errors.append("Missing required key 'nodes'")
elif not isinstance(data["nodes"], list):
errors.append("'nodes' must be a list")
else:
for i, node in enumerate(data["nodes"]):
if not isinstance(node, dict):
errors.append(f"Node {i} must be an object")
continue
for field in REQUIRED_NODE_FIELDS:
if field not in node:
errors.append(f"Node {i} (id={node.get('id', '?')!r}) missing required field '{field}'")
if "id" in node:
try:
hash(node["id"])
except TypeError:
errors.append(
f"Node {i} has non-hashable id {node['id']!r} - id must be a string"
)
else:
node_ids.add(node["id"])
if "file_type" in node and node["file_type"] not in VALID_FILE_TYPES:
errors.append(
f"Node {i} (id={node.get('id', '?')!r}) has invalid file_type "
f"'{node['file_type']}' - must be one of {sorted(VALID_FILE_TYPES)}"
)
# Edges - accept "links" (NetworkX <= 3.1) as fallback for "edges"
edge_list = data.get("edges") if "edges" in data else data.get("links")
if edge_list is None:
errors.append("Missing required key 'edges'")
elif not isinstance(edge_list, list):
errors.append("'edges' must be a list")
else:
for i, edge in enumerate(edge_list):
if not isinstance(edge, dict):
errors.append(f"Edge {i} must be an object")
continue
for field in REQUIRED_EDGE_FIELDS:
if field not in edge:
errors.append(f"Edge {i} missing required field '{field}'")
if "confidence" in edge and edge["confidence"] not in VALID_CONFIDENCES:
errors.append(
f"Edge {i} has invalid confidence '{edge['confidence']}' "
f"- must be one of {sorted(VALID_CONFIDENCES)}"
)
for endpoint in ("source", "target"):
if endpoint not in edge:
continue
val = edge[endpoint]
try:
unmatched = bool(node_ids) and val not in node_ids
except TypeError:
errors.append(
f"Edge {i} {endpoint} {val!r} is non-hashable - must be a string"
)
continue
if unmatched:
errors.append(f"Edge {i} {endpoint} '{val}' does not match any node id")
return errors
def assert_valid(data: dict) -> None:
"""Raise ValueError with all errors if extraction is invalid."""
errors = validate_extraction(data)
if errors:
msg = f"Extraction JSON has {len(errors)} error(s):\n" + "\n".join(f" • {e}" for e in errors)
raise ValueError(msg)