diff --git a/graphify/build.py b/graphify/build.py index caeaefdf9..bc8fce1d6 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -30,7 +30,7 @@ import networkx as nx from .ids import make_id, normalize_id as _normalize_id from .paths import default_graph_json as _default_graph_json -from .validate import validate_extraction +from .validate import validate_extraction, VALID_FILE_TYPES # Language interop families, keyed by extension, for the cross-language phantom-edge @@ -72,6 +72,20 @@ "framework": "concept", } +# Biomedical entity categories that semantic subagents are told to record in a +# node's `entry_type` (see the biomedical extraction rules in the /graphify +# skill). Models sometimes leak the category into `file_type` instead, where it +# is not a valid file_type. Unlike the generic synonyms above — which collapse +# unknown types to `concept` and discard the original token — these categories +# carry real information, so we preserve them in `entry_type` rather than lose +# them. `technology` is intentionally also present in _FILE_TYPE_SYNONYMS (it is +# ambiguous with the code-corpus sense), so it is only recovered as biomedical +# when the node already shows a biomedical signal (an existing `entry_type`). +_BIOMEDICAL_CATEGORIES = { + "drug", "gene", "disease", "protein", "technology", + "peptide", "institution", "company", +} + # Hyperedge member lists are canonically keyed `nodes` (see graphify/llm.py # extraction spec), but LLM/subagent drift and externally-supplied graph.json @@ -428,8 +442,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if node.get("file_type") in (None, ""): node["file_type"] = "concept" ft = node.get("file_type", "") - if ft and ft not in {"code", "document", "paper", "image", "rationale", "concept"}: - node["file_type"] = _FILE_TYPE_SYNONYMS.get(ft, "concept") + if ft and ft not in VALID_FILE_TYPES: + # A leaked biomedical entity category (drug/gene/disease/...) carries + # real information, so preserve it in entry_type instead of + # collapsing it to `concept` and discarding it. `technology` is + # ambiguous with the code-corpus synonym, so only treat it as + # biomedical when the node already carries a biomedical signal. + if ft in _BIOMEDICAL_CATEGORIES and ( + node.get("entry_type") or ft not in _FILE_TYPE_SYNONYMS + ): + node.setdefault("entry_type", ft) + node["file_type"] = "document" + else: + node["file_type"] = _FILE_TYPE_SYNONYMS.get(ft, "concept") # Canonicalize hyperedge member lists (#1561): producers sometimes key the # member list `members`/`node_ids` instead of `nodes`. Fold aliases onto diff --git a/tests/test_build.py b/tests/test_build.py index ed87e6b11..1c9bbf9b6 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -994,3 +994,55 @@ def test_build_from_json_prunes_dangling_hyperedge_members(capsys): assert set(hes) == {"he_partial"}, "an all-dangling hyperedge must be dropped" assert hes["he_partial"]["nodes"] == ["alpha", "beta"] assert "he_all_ghost" in capsys.readouterr().err + + +def test_leaked_biomedical_category_recovered_into_entry_type(): + # #(new): biomedical semantic subagents are told to put the entity category + # (drug/gene/disease/...) in `entry_type`, but models sometimes also write + # it to `file_type`, which is not a valid file_type. Recover the category + # into entry_type and coerce file_type to a valid `document` — without + # emitting an invalid-file_type warning and without discarding the category. + ext = { + "nodes": [ + # peptide is biomedical-exclusive: recovered even without entry_type + {"id": "phlip", "label": "pHLIP", "file_type": "peptide", + "source_file": "patents/records/x.md"}, + # entry_type already correct; must not be overwritten + {"id": "mitocatch", "label": "MitoCatch", "file_type": "technology", + "entry_type": "technology", "source_file": "patents/records/y.md"}, + ], + "edges": [], + } + G = build_from_json(ext) + assert G.nodes["phlip"]["file_type"] == "document" + assert G.nodes["phlip"]["entry_type"] == "peptide" + assert G.nodes["mitocatch"]["file_type"] == "document" + assert G.nodes["mitocatch"]["entry_type"] == "technology" + + +def test_ambiguous_technology_without_signal_stays_code_synonym(): + # `technology` is ambiguous with the code-corpus synonym (-> concept). With + # no biomedical signal (no entry_type), preserve the existing behavior so + # non-biomedical corpora are unaffected. + ext = { + "nodes": [ + {"id": "n1", "label": "Some Tech", "file_type": "technology", + "source_file": "docs/a.md"}, + ], + "edges": [], + } + G = build_from_json(ext) + assert G.nodes["n1"]["file_type"] == "concept" + assert "entry_type" not in G.nodes["n1"] + + +def test_unknown_invalid_file_type_still_falls_back_to_concept(): + ext = { + "nodes": [ + {"id": "n1", "label": "Junk", "file_type": "gizmo", + "source_file": "docs/a.md"}, + ], + "edges": [], + } + G = build_from_json(ext) + assert G.nodes["n1"]["file_type"] == "concept"