mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 09:17:02 +00:00
#796: add edge_data()/edge_datas() helpers in build.py that tolerate MultiGraph/MultiDiGraph; replace all G.edges[u,v] 2-tuple call sites in __main__.py, serve.py, wiki.py, export.py, analyze.py, benchmark.py; fix same pattern in 10 skill file inline heredocs #795: all 12 skill files now short-circuit on /graphify --help or -h and print the Usage block without running any pipeline steps #792 (hollow response): add _response_is_hollow() predicate in llm.py; when Ollama (or any backend) returns empty/null/whitespace content or a parsed result with no nodes/edges, rewrite finish_reason="length" so _extract_with_adaptive_retry bisects the chunk instead of silently dropping it; applied to _call_openai_compat, _call_claude, _call_bedrock Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
dc50979a68
commit
23f598f3a0
@@ -1514,9 +1514,10 @@ def main() -> None:
|
||||
sys.exit(0)
|
||||
hops = len(path_nodes) - 1
|
||||
segments = []
|
||||
from graphify.build import edge_data
|
||||
for i in range(len(path_nodes) - 1):
|
||||
u, v = path_nodes[i], path_nodes[i + 1]
|
||||
edata = G.edges[u, v]
|
||||
edata = edge_data(G, u, v)
|
||||
rel = edata.get("relation", "")
|
||||
conf = edata.get("confidence", "")
|
||||
conf_str = f" [{conf}]" if conf else ""
|
||||
@@ -1562,9 +1563,10 @@ def main() -> None:
|
||||
print(f" Degree: {G.degree(nid)}")
|
||||
neighbors = list(G.neighbors(nid))
|
||||
if neighbors:
|
||||
from graphify.build import edge_data
|
||||
print(f"\nConnections ({len(neighbors)}):")
|
||||
for nb in sorted(neighbors, key=lambda n: G.degree(n), reverse=True)[:20]:
|
||||
edata = G.edges[nid, nb]
|
||||
edata = edge_data(G, nid, nb)
|
||||
rel = edata.get("relation", "")
|
||||
conf = edata.get("confidence", "")
|
||||
print(f" --> {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]")
|
||||
|
||||
+3
-1
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import networkx as nx
|
||||
|
||||
from graphify.build import edge_data
|
||||
|
||||
# Language families — extensions sharing a runtime can legitimately call each other
|
||||
_LANG_FAMILY: dict[str, str] = {
|
||||
**{e: "python" for e in (".py", ".pyw")},
|
||||
@@ -301,7 +303,7 @@ def _cross_community_surprises(
|
||||
top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n]
|
||||
result = []
|
||||
for (u, v), score in top_edges:
|
||||
data = G.edges[u, v]
|
||||
data = edge_data(G, u, v)
|
||||
result.append({
|
||||
"source": G.nodes[u].get("label", u),
|
||||
"target": G.nodes[v].get("label", v),
|
||||
|
||||
@@ -6,6 +6,8 @@ from pathlib import Path
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
|
||||
from graphify.build import edge_data
|
||||
|
||||
|
||||
_CHARS_PER_TOKEN = 4 # standard approximation
|
||||
|
||||
@@ -66,7 +68,7 @@ def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int:
|
||||
lines.append(f"NODE {d.get('label', nid)} src={d.get('source_file', '')} loc={d.get('source_location', '')}")
|
||||
for u, v in edges_seen:
|
||||
if u in visited and v in visited:
|
||||
d = G.edges[u, v]
|
||||
d = edge_data(G, u, v)
|
||||
lines.append(f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')}--> {G.nodes[v].get('label', v)}")
|
||||
|
||||
return _estimate_tokens("\n".join(lines))
|
||||
|
||||
@@ -45,6 +45,27 @@ def _norm_source_file(p: str | None) -> str | None:
|
||||
return p.replace("\\", "/") if p else p
|
||||
|
||||
|
||||
def edge_data(G: nx.Graph, u: str, v: str) -> dict:
|
||||
"""Return one edge attribute dict for (u, v), tolerating MultiGraph.
|
||||
|
||||
For MultiGraph/MultiDiGraph there can be multiple parallel edges;
|
||||
this returns the first one (sufficient for callers that only need
|
||||
relation/confidence for rendering). Fixes #796.
|
||||
"""
|
||||
raw = G[u][v]
|
||||
if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)):
|
||||
return next(iter(raw.values()), {})
|
||||
return raw
|
||||
|
||||
|
||||
def edge_datas(G: nx.Graph, u: str, v: str) -> list[dict]:
|
||||
"""Return every edge attribute dict for (u, v); always a list."""
|
||||
raw = G[u][v]
|
||||
if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)):
|
||||
return list(raw.values())
|
||||
return [raw]
|
||||
|
||||
|
||||
def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
|
||||
"""Build a NetworkX graph from an extraction dict.
|
||||
|
||||
|
||||
+4
-3
@@ -10,6 +10,7 @@ import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from graphify.security import sanitize_label
|
||||
from graphify.analyze import _node_community_map
|
||||
from graphify.build import edge_data
|
||||
|
||||
def _obsidian_tag(name: str) -> str:
|
||||
"""Sanitize a community name for use as an Obsidian tag.
|
||||
@@ -802,10 +803,10 @@ def to_obsidian(
|
||||
if neighbors:
|
||||
lines.append("## Connections")
|
||||
for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)):
|
||||
edge_data = G.edges[node_id, neighbor]
|
||||
edata = edge_data(G, node_id, neighbor)
|
||||
neighbor_label = node_filename[neighbor]
|
||||
relation = edge_data.get("relation", "")
|
||||
confidence = edge_data.get("confidence", "EXTRACTED")
|
||||
relation = edata.get("relation", "")
|
||||
confidence = edata.get("confidence", "EXTRACTED")
|
||||
lines.append(f"- [[{neighbor_label}]] - `{relation}` [{confidence}]")
|
||||
lines.append("")
|
||||
|
||||
|
||||
+59
-3
@@ -170,6 +170,26 @@ def _parse_llm_json(raw: str) -> dict:
|
||||
return {"nodes": [], "edges": [], "hyperedges": []}
|
||||
|
||||
|
||||
def _response_is_hollow(raw_content: str | None, parsed: dict) -> bool:
|
||||
"""Detect a successful HTTP response that yielded no usable extraction.
|
||||
|
||||
A local model under load (most often Ollama) can return HTTP 200 with an
|
||||
empty / null `message.content`, with whitespace, or with a half-generated
|
||||
JSON prefix that fails to parse. All of these collapse to a "successful"
|
||||
call producing zero nodes and zero edges. Without this check the chunk
|
||||
is silently dropped from the corpus because no exception is raised and
|
||||
`finish_reason` is `"stop"` rather than `"length"`. By flagging the
|
||||
result as hollow, callers can re-route it through the same bisection
|
||||
path used for context-window overflow and `finish_reason="length"`.
|
||||
"""
|
||||
if raw_content is None or not raw_content.strip():
|
||||
return True
|
||||
nodes = parsed.get("nodes")
|
||||
edges = parsed.get("edges")
|
||||
hyperedges = parsed.get("hyperedges")
|
||||
return not nodes and not edges and not hyperedges
|
||||
|
||||
|
||||
def _backend_env_keys(backend: str) -> list[str]:
|
||||
"""Return accepted API-key environment variables for a backend."""
|
||||
cfg = BACKENDS[backend]
|
||||
@@ -260,7 +280,8 @@ def _call_openai_compat(
|
||||
if "moonshot" in base_url:
|
||||
kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
|
||||
resp = client.chat.completions.create(**kwargs)
|
||||
result = _parse_llm_json(resp.choices[0].message.content or "{}")
|
||||
raw_content = resp.choices[0].message.content
|
||||
result = _parse_llm_json(raw_content or "{}")
|
||||
result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0
|
||||
result["output_tokens"] = resp.usage.completion_tokens if resp.usage else 0
|
||||
result["model"] = model
|
||||
@@ -268,6 +289,20 @@ def _call_openai_compat(
|
||||
# mid-generation. The JSON we got back is truncated; callers should
|
||||
# treat this as a signal to retry with smaller input.
|
||||
result["finish_reason"] = resp.choices[0].finish_reason
|
||||
# An overwhelmed local model (typically Ollama) can return HTTP 200 with
|
||||
# empty / null content or unparseable half-generated JSON. The call looks
|
||||
# successful, `finish_reason` is `"stop"`, and the chunk would be silently
|
||||
# dropped from the corpus. Re-label as `"length"` so the adaptive retry
|
||||
# layer bisects the chunk — same recovery as a true truncation.
|
||||
if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length":
|
||||
print(
|
||||
f"[graphify] {backend or 'backend'} returned a hollow response "
|
||||
f"(content={'empty' if not (raw_content or '').strip() else 'no nodes/edges'}, "
|
||||
f"output_tokens={result['output_tokens']}); "
|
||||
"treating as truncation so adaptive retry can bisect the chunk.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
result["finish_reason"] = "length"
|
||||
output_tokens = result["output_tokens"]
|
||||
if output_tokens < 50 and backend == "ollama":
|
||||
print(
|
||||
@@ -296,7 +331,8 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int =
|
||||
system=_EXTRACTION_SYSTEM,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
)
|
||||
result = _parse_llm_json(resp.content[0].text if resp.content else "{}")
|
||||
raw_content = resp.content[0].text if resp.content else None
|
||||
result = _parse_llm_json(raw_content or "{}")
|
||||
result["input_tokens"] = resp.usage.input_tokens if resp.usage else 0
|
||||
result["output_tokens"] = resp.usage.output_tokens if resp.usage else 0
|
||||
result["model"] = model
|
||||
@@ -304,6 +340,13 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int =
|
||||
# vocabulary so the adaptive-retry layer doesn't have to know which
|
||||
# backend produced the result.
|
||||
result["finish_reason"] = "length" if resp.stop_reason == "max_tokens" else "stop"
|
||||
if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length":
|
||||
print(
|
||||
"[graphify] claude returned a hollow response; treating as "
|
||||
"truncation so adaptive retry can bisect the chunk.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
result["finish_reason"] = "length"
|
||||
return result
|
||||
|
||||
|
||||
@@ -341,6 +384,13 @@ def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192) -> dict
|
||||
result["output_tokens"] = usage.get("outputTokens", 0)
|
||||
result["model"] = model
|
||||
result["finish_reason"] = "length" if resp.get("stopReason") == "max_tokens" else "stop"
|
||||
if _response_is_hollow(text, result) and result["finish_reason"] != "length":
|
||||
print(
|
||||
"[graphify] bedrock returned a hollow response; treating as "
|
||||
"truncation so adaptive retry can bisect the chunk.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
result["finish_reason"] = "length"
|
||||
return result
|
||||
|
||||
|
||||
@@ -505,7 +555,7 @@ def _extract_with_adaptive_retry(
|
||||
or the API rejects the prompt as too large for the model's context window,
|
||||
split the chunk in half and recurse.
|
||||
|
||||
Two signals drive the retry:
|
||||
Three signals drive the retry, all funnelled through the same code:
|
||||
|
||||
- `finish_reason == "length"` — the model accepted the input but ran out of
|
||||
`max_completion_tokens` mid-output. The truncated JSON is unparseable, so
|
||||
@@ -518,6 +568,12 @@ def _extract_with_adaptive_retry(
|
||||
half is the same recovery as for the `length` case and works for the
|
||||
same reason.
|
||||
|
||||
- hollow successful responses — the model returned HTTP 200 with empty,
|
||||
null, or unparseable content (typical of a local Ollama under load).
|
||||
`_call_openai_compat` re-labels these as `finish_reason="length"` so they
|
||||
take the same recovery path; without that the chunk would be silently
|
||||
dropped from the corpus.
|
||||
|
||||
Recursion is capped at `max_depth` to bound worst-case cost. A chunk of N
|
||||
files can split into up to 2**max_depth pieces — at depth=3 that's 8x. If
|
||||
still failing at the cap, we surface the (likely empty) result with a
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from graphify.security import sanitize_label
|
||||
from graphify.build import edge_data
|
||||
|
||||
|
||||
def _load_graph(graph_path: str) -> nx.Graph:
|
||||
@@ -403,7 +404,7 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
nid = matches[0]
|
||||
lines = [f"Neighbors of {sanitize_label(G.nodes[nid].get('label', nid))}:"]
|
||||
for neighbor in G.neighbors(nid):
|
||||
d = G.edges[nid, neighbor]
|
||||
d = edge_data(G, nid, neighbor)
|
||||
rel = d.get("relation", "")
|
||||
if rel_filter and rel_filter not in rel.lower():
|
||||
continue
|
||||
@@ -466,7 +467,7 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
segments = []
|
||||
for i in range(len(path_nodes) - 1):
|
||||
u, v = path_nodes[i], path_nodes[i + 1]
|
||||
edata = G.edges[u, v]
|
||||
edata = edge_data(G, u, v)
|
||||
rel = edata.get("relation", "")
|
||||
conf = edata.get("confidence", "")
|
||||
conf_str = f" [{conf}]" if conf else ""
|
||||
|
||||
@@ -51,6 +51,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -952,7 +954,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1024,7 +1026,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1094,7 +1096,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -51,6 +51,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -952,7 +954,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1024,7 +1026,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1094,7 +1096,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -51,6 +51,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -1011,7 +1013,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1083,7 +1085,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1153,7 +1155,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -53,6 +53,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -1040,7 +1042,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1112,7 +1114,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1182,7 +1184,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -51,6 +51,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -1008,7 +1010,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1080,7 +1082,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1150,7 +1152,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -50,6 +50,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -951,7 +953,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1023,7 +1025,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1093,7 +1095,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -51,6 +51,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -1061,7 +1063,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1133,7 +1135,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1203,7 +1205,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -50,6 +50,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -951,7 +953,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1023,7 +1025,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1093,7 +1095,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -51,6 +51,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -976,7 +978,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")}] [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1048,7 +1050,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1117,7 +1119,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
@@ -21,6 +21,8 @@ Turn any folder of files into a navigable knowledge graph with community detecti
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
|
||||
@@ -54,6 +54,8 @@ Use it for:
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
@@ -1090,7 +1092,7 @@ for nid in ranked_nodes:
|
||||
lines.append(f' NODE {d.get("label", nid)} [src={d.get("source_file","")} loc={d.get("source_location","")}]')
|
||||
for u, v in subgraph_edges:
|
||||
if u in subgraph_nodes and v in subgraph_nodes:
|
||||
d = G.edges[u, v]
|
||||
_raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
lines.append(f' EDGE {G.nodes[u].get("label",u)} --{d.get("relation","")} [{d.get("confidence","")}]--> {G.nodes[v].get("label",v)}')
|
||||
|
||||
output = '\n'.join(lines)
|
||||
@@ -1166,7 +1168,7 @@ try:
|
||||
for i, nid in enumerate(path):
|
||||
label = G.nodes[nid].get('label', nid)
|
||||
if i < len(path) - 1:
|
||||
edge = G.edges[nid, path[i+1]]
|
||||
_raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
print(f' {label} --{rel}--> [{conf}]')
|
||||
@@ -1240,7 +1242,7 @@ print(f' degree: {G.degree(nid)}')
|
||||
print()
|
||||
print('CONNECTIONS:')
|
||||
for neighbor in G.neighbors(nid):
|
||||
edge = G.edges[nid, neighbor]
|
||||
_raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw
|
||||
nlabel = G.nodes[neighbor].get('label', neighbor)
|
||||
rel = edge.get('relation', '')
|
||||
conf = edge.get('confidence', '')
|
||||
|
||||
+3
-1
@@ -47,9 +47,11 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL — run Step 0 before anything else, then continue with the resolved local path.
|
||||
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
|
||||
|
||||
+4
-2
@@ -5,6 +5,8 @@ from collections import Counter
|
||||
from pathlib import Path
|
||||
import networkx as nx
|
||||
|
||||
from graphify.build import edge_data
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Make a label safe for use as a filename across platforms.
|
||||
@@ -48,7 +50,7 @@ def _community_article(
|
||||
conf_counts: Counter = Counter()
|
||||
for nid in nodes:
|
||||
for neighbor in G.neighbors(nid):
|
||||
ed = G.edges[nid, neighbor]
|
||||
ed = edge_data(G, nid, neighbor)
|
||||
conf_counts[ed.get("confidence", "EXTRACTED")] += 1
|
||||
total_edges = sum(conf_counts.values()) or 1
|
||||
|
||||
@@ -118,7 +120,7 @@ def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str]) -> str:
|
||||
by_relation: dict[str, list[str]] = {}
|
||||
for neighbor in sorted(G.neighbors(nid), key=lambda n: G.degree(n), reverse=True):
|
||||
nd = G.nodes[neighbor]
|
||||
ed = G.edges[nid, neighbor]
|
||||
ed = edge_data(G, nid, neighbor)
|
||||
rel = ed.get("relation", "related")
|
||||
neighbor_label = nd.get("label", neighbor)
|
||||
conf = ed.get("confidence", "")
|
||||
|
||||
+87
-1
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from graphify.build import build_from_json, build, build_merge
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from graphify.build import build_from_json, build, build_merge, edge_data, edge_datas
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
@@ -195,3 +197,87 @@ def test_build_merge_preserves_call_edge_direction(tmp_path):
|
||||
f"calls edge target flipped after build_merge round-trip: "
|
||||
f"expected {truth_tgt} (b), got {reloaded_calls[0]['target']}"
|
||||
)
|
||||
|
||||
|
||||
# Regression tests for #796 — edge_data / edge_datas helpers must tolerate
|
||||
# MultiGraph and MultiDiGraph, which networkx's node_link_graph() produces
|
||||
# whenever the loaded JSON has multigraph: true. Plain G.edges[u, v] crashes
|
||||
# on those with `ValueError: not enough values to unpack (expected 3, got 2)`.
|
||||
|
||||
def test_edge_data_simple_graph():
|
||||
G = nx.Graph()
|
||||
G.add_edge("a", "b", relation="calls", confidence="EXTRACTED")
|
||||
d = edge_data(G, "a", "b")
|
||||
assert isinstance(d, dict)
|
||||
assert d["relation"] == "calls"
|
||||
assert d["confidence"] == "EXTRACTED"
|
||||
|
||||
|
||||
def test_edge_datas_simple_graph_returns_singleton_list():
|
||||
G = nx.Graph()
|
||||
G.add_edge("a", "b", relation="calls", confidence="EXTRACTED")
|
||||
ds = edge_datas(G, "a", "b")
|
||||
assert isinstance(ds, list)
|
||||
assert len(ds) == 1
|
||||
assert ds[0]["relation"] == "calls"
|
||||
|
||||
|
||||
def test_edge_data_multigraph_with_parallel_edges():
|
||||
G = nx.MultiGraph()
|
||||
G.add_edge("a", "b", relation="calls", confidence="EXTRACTED")
|
||||
G.add_edge("a", "b", relation="references", confidence="INFERRED")
|
||||
d = edge_data(G, "a", "b")
|
||||
assert isinstance(d, dict)
|
||||
# First parallel edge wins; should be one of the two attribute dicts above.
|
||||
assert d.get("relation") in ("calls", "references")
|
||||
|
||||
|
||||
def test_edge_datas_multigraph_returns_all_parallel_edges():
|
||||
G = nx.MultiGraph()
|
||||
G.add_edge("a", "b", relation="calls", confidence="EXTRACTED")
|
||||
G.add_edge("a", "b", relation="references", confidence="INFERRED")
|
||||
ds = edge_datas(G, "a", "b")
|
||||
assert isinstance(ds, list)
|
||||
assert len(ds) == 2
|
||||
relations = {e.get("relation") for e in ds}
|
||||
assert relations == {"calls", "references"}
|
||||
|
||||
|
||||
def test_edge_data_multidigraph():
|
||||
G = nx.MultiDiGraph()
|
||||
G.add_edge("a", "b", relation="calls")
|
||||
G.add_edge("a", "b", relation="imports")
|
||||
d = edge_data(G, "a", "b")
|
||||
assert isinstance(d, dict)
|
||||
assert d.get("relation") in ("calls", "imports")
|
||||
ds = edge_datas(G, "a", "b")
|
||||
assert len(ds) == 2
|
||||
|
||||
|
||||
def test_edge_data_node_link_multigraph_roundtrip():
|
||||
"""A node_link JSON with multigraph: true must load as MultiGraph and the
|
||||
helpers must operate on it without raising the 3-tuple unpack ValueError."""
|
||||
data = {
|
||||
"directed": False,
|
||||
"multigraph": True,
|
||||
"graph": {},
|
||||
"nodes": [
|
||||
{"id": "a", "label": "A"},
|
||||
{"id": "b", "label": "B"},
|
||||
],
|
||||
"links": [
|
||||
{"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED"},
|
||||
{"source": "a", "target": "b", "relation": "references", "confidence": "INFERRED"},
|
||||
],
|
||||
}
|
||||
try:
|
||||
G = json_graph.node_link_graph(data, edges="links")
|
||||
except TypeError:
|
||||
G = json_graph.node_link_graph(data)
|
||||
assert isinstance(G, nx.MultiGraph)
|
||||
# Plain G.edges[u, v] would raise here; the helper must not.
|
||||
d = edge_data(G, "a", "b")
|
||||
assert isinstance(d, dict)
|
||||
assert d.get("relation") in ("calls", "references")
|
||||
ds = edge_datas(G, "a", "b")
|
||||
assert len(ds) == 2
|
||||
|
||||
@@ -187,3 +187,176 @@ def test_adaptive_retry_re_raises_unrelated_errors(tmp_path):
|
||||
llm._extract_with_adaptive_retry(
|
||||
[f], backend="kimi", api_key="k", model="m", root=tmp_path, max_depth=3
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hollow-response detection: empty / null / unparseable content from a
|
||||
# successful HTTP call must route into the same bisection path as a true
|
||||
# `finish_reason="length"` truncation, not be silently dropped.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_response_is_hollow_flags_empty_string():
|
||||
assert llm._response_is_hollow("", {"nodes": [], "edges": [], "hyperedges": []})
|
||||
|
||||
|
||||
def test_response_is_hollow_flags_none_content():
|
||||
assert llm._response_is_hollow(None, {"nodes": [], "edges": [], "hyperedges": []})
|
||||
|
||||
|
||||
def test_response_is_hollow_flags_whitespace_only():
|
||||
assert llm._response_is_hollow(" \n\t ", {"nodes": [], "edges": [], "hyperedges": []})
|
||||
|
||||
|
||||
def test_response_is_hollow_flags_parsed_but_no_nodes_or_edges():
|
||||
# Content was non-empty (e.g. model said `{"sorry": "I cannot"}` or returned
|
||||
# `{}` literally) but the parsed result has nothing usable.
|
||||
assert llm._response_is_hollow('{"sorry": "I cannot"}', {})
|
||||
assert llm._response_is_hollow("{}", {"nodes": [], "edges": [], "hyperedges": []})
|
||||
|
||||
|
||||
def test_response_is_hollow_accepts_real_extraction():
|
||||
parsed = {"nodes": [{"id": "x"}], "edges": [], "hyperedges": []}
|
||||
assert not llm._response_is_hollow('{"nodes":[{"id":"x"}]}', parsed)
|
||||
parsed = {"nodes": [], "edges": [{"source": "a", "target": "b"}], "hyperedges": []}
|
||||
assert not llm._response_is_hollow('{"edges":[...]}', parsed)
|
||||
|
||||
|
||||
def _fake_openai_response(content, *, finish_reason="stop", prompt_tokens=100, completion_tokens=0):
|
||||
"""Build a minimal stand-in for an `openai` SDK ChatCompletion response."""
|
||||
class _Usage:
|
||||
def __init__(self):
|
||||
self.prompt_tokens = prompt_tokens
|
||||
self.completion_tokens = completion_tokens
|
||||
|
||||
class _Message:
|
||||
def __init__(self):
|
||||
self.content = content
|
||||
|
||||
class _Choice:
|
||||
def __init__(self):
|
||||
self.message = _Message()
|
||||
self.finish_reason = finish_reason
|
||||
|
||||
class _Resp:
|
||||
def __init__(self):
|
||||
self.choices = [_Choice()]
|
||||
self.usage = _Usage()
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _install_fake_openai(monkeypatch, fake_resp):
|
||||
"""Inject a stub `openai` module so `_call_openai_compat` can run without
|
||||
the real SDK installed. The function does `from openai import OpenAI`
|
||||
inside its body, so we satisfy that lookup via `sys.modules`."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
class _FakeOpenAI:
|
||||
def __init__(self, *_, **__):
|
||||
self.chat = self
|
||||
self.completions = self
|
||||
def create(self, **__):
|
||||
return fake_resp
|
||||
|
||||
fake_module = types.ModuleType("openai")
|
||||
fake_module.OpenAI = _FakeOpenAI
|
||||
monkeypatch.setitem(sys.modules, "openai", fake_module)
|
||||
|
||||
|
||||
def test_call_openai_compat_relabels_empty_content_as_length(monkeypatch):
|
||||
# Simulates an overwhelmed Ollama: HTTP 200, empty content, finish_reason
|
||||
# "stop", zero completion tokens. Pre-fix this would silently return an
|
||||
# empty fragment and the chunk would be dropped. Post-fix `finish_reason`
|
||||
# is rewritten to "length" so the adaptive retry layer bisects.
|
||||
fake_resp = _fake_openai_response("", finish_reason="stop", completion_tokens=0)
|
||||
_install_fake_openai(monkeypatch, fake_resp)
|
||||
|
||||
result = llm._call_openai_compat(
|
||||
"http://localhost:11434/v1", "ollama", "qwen2.5-coder:7b",
|
||||
"user msg", temperature=0, max_completion_tokens=8192, backend="ollama",
|
||||
)
|
||||
assert result["finish_reason"] == "length", (
|
||||
"empty content from a 'successful' call must be re-labelled so the "
|
||||
"adaptive retry layer treats it as a truncation and bisects the chunk"
|
||||
)
|
||||
|
||||
|
||||
def test_call_openai_compat_relabels_none_content_as_length(monkeypatch):
|
||||
fake_resp = _fake_openai_response(None, finish_reason="stop")
|
||||
_install_fake_openai(monkeypatch, fake_resp)
|
||||
|
||||
result = llm._call_openai_compat(
|
||||
"http://localhost:11434/v1", "ollama", "qwen2.5-coder:7b",
|
||||
"u", temperature=0, max_completion_tokens=8192, backend="ollama",
|
||||
)
|
||||
assert result["finish_reason"] == "length"
|
||||
|
||||
|
||||
def test_call_openai_compat_relabels_unparseable_json_as_length(monkeypatch):
|
||||
# A half-generated response: `{"nodes": [{"id":` parses to {} (empty
|
||||
# fragment) via _parse_llm_json's JSONDecodeError fallback. That is also
|
||||
# hollow and must trigger bisection.
|
||||
fake_resp = _fake_openai_response('{"nodes": [{"id":', finish_reason="stop", completion_tokens=20)
|
||||
_install_fake_openai(monkeypatch, fake_resp)
|
||||
|
||||
result = llm._call_openai_compat(
|
||||
"http://localhost:11434/v1", "ollama", "qwen2.5-coder:7b",
|
||||
"u", temperature=0, max_completion_tokens=8192, backend="ollama",
|
||||
)
|
||||
assert result["finish_reason"] == "length"
|
||||
|
||||
|
||||
def test_call_openai_compat_preserves_real_finish_reason(monkeypatch):
|
||||
# A genuine extraction with real nodes must NOT be re-labelled.
|
||||
fake_resp = _fake_openai_response(
|
||||
'{"nodes":[{"id":"a"}],"edges":[],"hyperedges":[]}',
|
||||
finish_reason="stop",
|
||||
completion_tokens=200,
|
||||
)
|
||||
_install_fake_openai(monkeypatch, fake_resp)
|
||||
|
||||
result = llm._call_openai_compat(
|
||||
"http://localhost:11434/v1", "k", "m",
|
||||
"u", temperature=0, max_completion_tokens=8192, backend="kimi",
|
||||
)
|
||||
assert result["finish_reason"] == "stop"
|
||||
assert result["nodes"] == [{"id": "a"}]
|
||||
|
||||
|
||||
def test_adaptive_retry_bisects_on_hollow_ollama_response(tmp_path):
|
||||
# End-to-end: an overwhelmed Ollama returns hollow on the full 4-file
|
||||
# chunk; halves succeed. The bug being fixed is that pre-fix this
|
||||
# produces zero nodes (chunk silently dropped). Post-fix the hollow
|
||||
# response is relabelled `finish_reason="length"` and the existing
|
||||
# bisection path recovers the full 4 nodes.
|
||||
files = [tmp_path / f"f{i}.md" for i in range(4)]
|
||||
for f in files:
|
||||
f.write_text("hello")
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_extract(chunk, *_, **__):
|
||||
calls["n"] += 1
|
||||
if len(chunk) == 4:
|
||||
# Hollow response: looks successful, finish_reason already
|
||||
# rewritten to "length" by _call_openai_compat.
|
||||
return {
|
||||
"nodes": [], "edges": [], "hyperedges": [],
|
||||
"input_tokens": 100, "output_tokens": 0,
|
||||
"model": "m", "finish_reason": "length",
|
||||
}
|
||||
return _ok(nodes=[{"id": f.stem} for f in chunk])
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=fake_extract):
|
||||
result = llm._extract_with_adaptive_retry(
|
||||
files, backend="ollama", api_key="ollama", model="qwen2.5-coder:7b",
|
||||
root=tmp_path, max_depth=3,
|
||||
)
|
||||
|
||||
assert len(result["nodes"]) == 4, (
|
||||
"bisection should recover all 4 nodes from the two halves after the "
|
||||
"full chunk came back hollow"
|
||||
)
|
||||
assert calls["n"] == 3 # 1 hollow + 2 successful halves
|
||||
|
||||
Reference in New Issue
Block a user