mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 18:37:12 +00:00
2c7cbb6530
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.
96 lines
4.0 KiB
Python
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)
|