mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 10:27:11 +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.
138 lines
4.9 KiB
Python
138 lines
4.9 KiB
Python
import pytest
|
|
from graphify.validate import validate_extraction, assert_valid
|
|
|
|
VALID = {
|
|
"nodes": [
|
|
{"id": "n1", "label": "Foo", "file_type": "code", "source_file": "foo.py"},
|
|
{"id": "n2", "label": "Bar", "file_type": "document", "source_file": "bar.md"},
|
|
],
|
|
"edges": [
|
|
{"source": "n1", "target": "n2", "relation": "references",
|
|
"confidence": "EXTRACTED", "source_file": "foo.py", "weight": 1.0},
|
|
],
|
|
}
|
|
|
|
def test_valid_passes():
|
|
assert validate_extraction(VALID) == []
|
|
|
|
def test_missing_nodes_key():
|
|
errors = validate_extraction({"edges": []})
|
|
assert any("nodes" in e for e in errors)
|
|
|
|
def test_missing_edges_key():
|
|
errors = validate_extraction({"nodes": []})
|
|
assert any("edges" in e for e in errors)
|
|
|
|
def test_not_a_dict():
|
|
errors = validate_extraction([])
|
|
assert len(errors) == 1
|
|
|
|
def test_invalid_file_type():
|
|
data = {
|
|
"nodes": [{"id": "n1", "label": "X", "file_type": "video", "source_file": "x.mp4"}],
|
|
"edges": [],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("file_type" in e for e in errors)
|
|
|
|
def test_invalid_confidence():
|
|
data = {
|
|
"nodes": [
|
|
{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
|
|
{"id": "n2", "label": "B", "file_type": "code", "source_file": "b.py"},
|
|
],
|
|
"edges": [
|
|
{"source": "n1", "target": "n2", "relation": "calls",
|
|
"confidence": "CERTAIN", "source_file": "a.py"},
|
|
],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("confidence" in e for e in errors)
|
|
|
|
def test_dangling_edge_source():
|
|
data = {
|
|
"nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}],
|
|
"edges": [
|
|
{"source": "missing_id", "target": "n1", "relation": "calls",
|
|
"confidence": "EXTRACTED", "source_file": "a.py"},
|
|
],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("source" in e and "missing_id" in e for e in errors)
|
|
|
|
def test_dangling_edge_target():
|
|
data = {
|
|
"nodes": [{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"}],
|
|
"edges": [
|
|
{"source": "n1", "target": "ghost", "relation": "calls",
|
|
"confidence": "EXTRACTED", "source_file": "a.py"},
|
|
],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("target" in e and "ghost" in e for e in errors)
|
|
|
|
def test_missing_node_field():
|
|
data = {
|
|
"nodes": [{"id": "n1", "label": "A", "source_file": "a.py"}], # missing file_type
|
|
"edges": [],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("file_type" in e for e in errors)
|
|
|
|
def test_assert_valid_raises_on_errors():
|
|
with pytest.raises(ValueError, match="error"):
|
|
assert_valid({"nodes": [], "edges": [], "oops": True, **{"nodes": "bad"}})
|
|
|
|
def test_assert_valid_passes_silently():
|
|
assert_valid(VALID) # should not raise
|
|
|
|
|
|
def test_non_hashable_node_id_reported_not_raised():
|
|
# A malformed LLM extraction can emit a list-valued id. The validator must
|
|
# report it as an error string (its documented contract) rather than crash
|
|
# with TypeError: unhashable type on set construction.
|
|
data = {
|
|
"nodes": [
|
|
{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
|
|
{"id": ["x", "y"], "label": "B", "file_type": "code", "source_file": "b.py"},
|
|
],
|
|
"edges": [],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("non-hashable id" in e for e in errors)
|
|
|
|
|
|
def test_non_hashable_edge_endpoint_reported_not_raised():
|
|
# A list-valued edge source/target must be reported, not crash the
|
|
# membership test against the node-id set.
|
|
data = {
|
|
"nodes": [
|
|
{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
|
|
{"id": "n2", "label": "B", "file_type": "code", "source_file": "b.py"},
|
|
],
|
|
"edges": [
|
|
{"source": "n1", "target": ["n2", "n3"], "relation": "calls",
|
|
"confidence": "INFERRED", "source_file": "a.py"},
|
|
],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("target" in e and "non-hashable" in e for e in errors)
|
|
|
|
|
|
def test_non_hashable_node_id_does_not_mask_valid_ids():
|
|
# The valid node id must still be collected so a legitimately-dangling edge
|
|
# is still flagged even when a sibling node has a bad id.
|
|
data = {
|
|
"nodes": [
|
|
{"id": "n1", "label": "A", "file_type": "code", "source_file": "a.py"},
|
|
{"id": {"oops": 1}, "label": "B", "file_type": "code", "source_file": "b.py"},
|
|
],
|
|
"edges": [
|
|
{"source": "n1", "target": "ghost", "relation": "calls",
|
|
"confidence": "EXTRACTED", "source_file": "a.py"},
|
|
],
|
|
}
|
|
errors = validate_extraction(data)
|
|
assert any("non-hashable id" in e for e in errors)
|
|
assert any("target" in e and "ghost" in e for e in errors)
|