Skip to content

Fix hollow-response loop in claude-cli backend - #1062

Closed
christophepub wants to merge 560 commits into
Graphify-Labs:mainfrom
christophepub:fix/claude-cli-hollow-responses
Closed

Fix hollow-response loop in claude-cli backend#1062
christophepub wants to merge 560 commits into
Graphify-Labs:mainfrom
christophepub:fix/claude-cli-hollow-responses

Conversation

@christophepub

Copy link
Copy Markdown
Contributor

Fix hollow-response loop in claude-cli backend

Problem

When running graphify extract <path> --backend claude-cli against a multi-modal corpus (code + Markdown docs), ~30-50% of semantic chunks come back as "hollow responses" and trigger adaptive bisection. On a ~800-file repo this turned a ~15 min run into ~44 min and consumed 2-3× more claude subscription quota than necessary.

Symptoms in the log:

[graphify] LLM returned invalid JSON, skipping chunk: Expecting value: line 1 column 1 (char 0)
[graphify] claude-cli returned a hollow response; treating as truncation so adaptive retry can bisect the chunk.
[graphify] chunk of 39 truncated at depth 0, splitting into halves of 19 and 20

The hollow-detection path works as designed — the issue is what causes Claude to return content that fails json.loads.

Root cause (two compounding issues)

1. _parse_llm_json only strips markdown fences at offset 0

if raw.startswith("```"):
    raw = raw.split("```", 2)[1]
    ...

Claude (and most chat models) frequently prepend a short preamble before the JSON, e.g.:

Here are the extracted entities:

```json
{"nodes": [...], "edges": [...]}

`raw.startswith("```")` returns `False`, the fence-stripping is skipped entirely, `json.loads("Here are the extracted entities:\n\n```json\n...")` fails, the chunk is dropped, the hollow detector re-routes it to the bisection path. Each bisected half is another `claude -p` call that may also come back with a preamble. Cost compounds.

### 2. `_call_claude_cli` uses `--append-system-prompt`

`--append-system-prompt` adds graphify's extraction prompt **on top of** Claude Code's default interactive-agent system prompt, which contains instructions like *"output text to communicate with the user"*, *"use markdown formatting"*, etc. These conflict with graphify's "return raw JSON only" instruction, and the default prompt wins about half the time — producing the preambles and markdown fences from issue #1.

The `claude` CLI exposes `--system-prompt` (replace) since at least 2.1.x, which is the right primitive for a headless extraction backend.

## Fix

Two complementary changes in `graphify/llm.py`:

**1. Robust JSON extraction in `_parse_llm_json`** — strips fences regardless of position, with a fallback that scans for the first balanced `{...}` object in the response. Handles preambles, trailing prose, and prose-wrapped JSON without fences.

**2. Switch `claude-cli` to `--system-prompt`** — eliminates the conflict at the source. Claude receives only graphify's extraction prompt and returns clean JSON on the first call. As a side benefit, cache-creation tokens per call drop ~19% (47k vs 58k in my measurements) because Claude Code's default system prompt is no longer materialized.

The two fixes are complementary: fix #2 dramatically reduces the rate of malformed responses; fix #1 keeps graphify robust against the residual cases (soft refusals, model confusion) and benefits every other backend too.

## Evidence

Test run on a 43-file `modes/` directory (Markdown docs):

| Metric | Before | After |
|---|---|---|
| Hollow responses | ~30-50% of chunks | **0** |
| Bisections triggered | several per run | **0** |
| Output tokens | inflated by preambles | clean |
| Cache-creation tokens / call | ~58k | ~47k (-19%) |
| Wall time on full ~800-file repo | ~44 min | ~15 min (estimated) |

Both fixes verified with isolated unit tests covering:
- preamble + fence (the primary bug)
- prose-wrapped JSON without fence
- raw JSON (regression check)
- model soft refusal (graceful empty return with diagnostic log)

## Trade-offs / risks

- `--system-prompt` replaces Claude Code's default prompt entirely. For the `-p` headless extraction use case this is desirable. Subscription auth is unaffected (verified via `is_error: False` test call).
- The balanced-brace scanner in strategy 2 is O(n) and only runs if both `json.loads(stripped)` and fence-stripping fail — no perf impact on the common path.
- `--no-session-persistence` is unchanged. A follow-up could explore session reuse to reclaim more cache budget, but that's orthogonal.

## Diff

Total: ~50 added lines, ~6 removed, in a single file (`graphify/llm.py`). Patch attached.

safishamsi and others added 30 commits May 4, 2026 18:07
…ression

Bring TypeScript AST extraction to parity with Java and C# by adding the
declaration types upstream graphify currently skips:

- interface_declaration (parity with Java/C# class_types)
- enum_declaration + members
- type_alias_declaration
- module-level const literals (object/array/string/call/new/template/number)
  via _js_extra_walk extension
- new_expression as call type for both _JS_CONFIG and _TS_CONFIG

Tested against tests/fixtures/typescript_advanced.ts: 8 expected node
types extracted (IUserRepository, UserStatus, UserId, USER_REPOSITORY,
DEFAULT_ROLES, USER_CONFIG, UserService, UserModule).

Validated on a 3,800-file TypeScript monorepo (NestJS + Next.js): yields
~1,885 interfaces, ~147 enums, ~405 type aliases, ~2,236 const literal
nodes, and ~1,935 instantiates edges that were previously invisible.
…ypass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_import_js previously only rewrote .js→.ts and .jsx→.tsx, leaving every
other common TypeScript / SvelteKit / Vite import shape unresolved. The
resulting node id wouldn't match the target file's own _make_id, so
build_from_json dropped the edge as external.

Three missed shapes:

  1. Bare paths (no extension) — TS convention:
     `import { foo } from './foo'`            → real file is foo.ts
  2. .svelte → .svelte.ts (Svelte 5 rune-only files):
     `import { x } from './x.svelte'`         → real file is x.svelte.ts
  3. Directory imports / barrel index files:
     `import { x } from './queue'`            → real file is queue/index.ts

Fix
---
New helper _resolve_with_extensions(p: Path) -> Path mirrors Vite/TS
resolver order:

  1. exact path (file)
  2. .js→.ts, .jsx→.tsx (existing TS-ESM convention)
  3. bare path → .ts/.tsx/.svelte/.js/.jsx/.mjs
  4. bare path → directory's index.{ts,tsx,js,jsx}
  5. .svelte → .svelte.ts (Svelte 5 rune file)

Falls back to the original path on no match — preserves pre-fix behaviour
for genuinely external modules (build_from_json drops them as phantoms).

Wired into _import_js (relative + alias branches) and extract_svelte's
regex pass for dynamic_import so static and dynamic imports both benefit.

Subtle: uses .is_file() / .is_dir() rather than .exists(). When the
import is a directory, .exists() returns True and would short-circuit
before the index.ts lookup ever ran.

Tests
-----
20 new tests in tests/test_import_extension_resolution.py:

  Resolver unit tests (12):
    - existing path returned unchanged
    - bare path → .ts / .tsx / .svelte
    - .ts wins over .svelte for ambiguous bare paths (Vite order)
    - directory → index.ts
    - directory prefers index.ts over index.js
    - .svelte → .svelte.ts (Svelte 5 rune file)
    - .js → .ts (TS ESM convention)
    - .jsx → .tsx
    - real .js stays .js when .ts doesn't exist
    - unresolvable returns input unchanged

  End-to-end (8):
    - bare-path import resolves in TS file
    - directory import resolves to index.ts
    - .svelte import resolves to .svelte.ts rune file
    - explicit .ts/.svelte imports still work (regression guard)
    - external module specifiers unchanged
    - alias + bare path resolves
    - dynamic_import bare path resolves
Adds 8 tests covering import shapes that came up during real-codebase
validation against a 1,873-file SvelteKit project:

  - test_type_only_import_with_bare_path_resolves
      `import type { X } from './foo'` — type-only imports must go
      through the same resolver. Common pattern in TS codebases.

  - test_named_imports_emit_symbol_edges_after_resolution
      `import { foo, bar } from './module'` — verifies the per-symbol
      `imports` edges (file → module.foo, file → module.bar) target the
      correct stem after resolution. The symbol target_stem comes from
      _file_stem(resolved), so resolution must happen first.

  - test_alias_directory_import_resolves_to_index_ts
      `from '$lib/queue'` — alias + directory composes correctly.

  - test_resolve_does_not_match_partial_directory_name
      Regression guard: `from './foo'` where only `foo-extra.ts` exists
      must NOT accidentally resolve to it.

  - test_resolve_directory_without_index_returns_unchanged
      A directory with no index.* must fall through, not pick a random
      .ts inside.

  - test_resolve_handles_subpath_into_directory_with_index
      `./foo/sub` where `./foo/sub/index.ts` exists.

  - test_resolve_does_not_treat_dotfile_as_extension
      Path('.env-types.ts').suffix is '.ts' (correct), but worth pinning.

  - test_resolve_chain_alias_and_extension_compose
      Two-layer resolution: alias → bare path → .svelte.ts. Verifies
      the full chain works end-to-end for the Svelte 5 rune-file case.

Also expanded test_named_imports_emit_symbol_edges_after_resolution to
catch a subtle regression class: per-symbol import edges (line 319-340
in _import_js) build their target id from _file_stem(resolved). If
resolution fails or returns the wrong path, the symbol edges silently
target a different stem and downstream "where is X used?" queries miss
real callers.
Two changes that landed together because they share the same code path:

1. Generalize the bare-path append to handle multi-dot filenames

   The previous resolver only appended extensions when path.suffix == ""
   (truly bare paths). Real codebases use a lot of multi-dot patterns:

     foo.shared.ts        ← imported as './foo.shared'
     foo.config.ts        ← imported as './foo.config'
     foo.compile.ts       ← imported as './foo.compile'
     foo.integration.ts   ← imported as './foo.integration' (test helper)
     foo.triggers.ts      ← imported as './foo.triggers'  (test helper)
     foo.svelte.ts        ← imported as './foo.svelte'    (Svelte 5 rune)
     foo.d.ts             ← imported as './foo.d'         (ambient types)

   For all of these, .suffix is the meaningful middle segment (.shared,
   .config, .integration, etc.) — not in the .js/.jsx/.svelte handled
   list, so the resolver fell through and the import dropped to a phantom.

   The fix unifies the bare-path and .svelte→.svelte.ts cases into a
   single rule: append each candidate extension to the FULL filename, not
   to the stripped stem. This subsumes:

     bare path:           foo           → foo.ts
     Svelte rune file:    foo.svelte    → foo.svelte.ts
     multi-dot helper:    foo.shared    → foo.shared.ts
     ambient declaration: foo.d         → foo.d.ts

   No behaviour change for paths that DO exist (.is_file() short-circuit)
   or for the .js→.ts / .jsx→.tsx convention (handled before the append
   loop so we don't accidentally match foo.js → foo.js.ts when foo.ts
   is the real file).

2. Rename _resolve_with_extensions → _resolve_js_module_path

   The function is JS/TS/Svelte-specific (Vite resolver order, mirrors the
   convention used by _import_js, _JS_CONFIG, _TS_CONFIG). The original
   name suggested it was a generic path utility. Renamed to make scope
   explicit and align with the existing _import_js / _JS_CONFIG naming
   pattern. Constants renamed to match: _JS_RESOLVE_EXTS, _JS_INDEX_FILES.

Tests
-----
4 new tests in tests/test_import_extension_resolution.py:

  - test_resolve_multi_dot_helper_file: foo.shared → foo.shared.ts
  - test_resolve_multi_dot_with_explicit_extension_still_works:
    foo.shared.ts (explicit) still wins
  - test_resolve_ambient_d_ts_via_bare_path: foo.d → foo.d.ts
  - test_end_to_end_multi_dot_import_resolves: tree-sitter pipeline
    sanity check via extract_js

Existing 28 tests updated for the rename. 32/32 pass; 7 pre-existing
unrelated failures elsewhere in the suite.

Validation
----------
On a 1,873-file SvelteKit codebase, applying both rules over the v0.7.5
baseline:

  baseline:                 12,096 edges
  with the resolver fix:    20,151 edges  (+8,055 = +67%)

The +2,652 over the previous version of this branch is attributable
entirely to multi-dot filename recovery, primarily test helper imports
('*.integration.ts', '*.triggers.ts'), domain-shared modules
('*.shared.ts'), and config files.
The generalized resolver already handles .svelte.js because the append
loop iterates _JS_RESOLVE_EXTS = (.ts, .tsx, .svelte, .js, .jsx, .mjs).
Adds three explicit tests to pin the behaviour and document the priority
choice:

  - test_resolve_svelte_to_svelte_js_for_javascript_rune_files
      JS-only Svelte 5 project: .svelte → .svelte.js works the same
      way as .svelte.ts in TS projects. No special-casing needed —
      the generalized append loop covers both.

  - test_resolve_svelte_prefers_svelte_ts_over_svelte_js
      Hybrid case (both files exist, e.g. .svelte.ts source plus
      .svelte.js build artifact): .ts wins. Documents the deliberate
      source-first priority — graphify is a source-code tool, not a
      runtime resolver, so we differ from Vite's default JS-first order.

  - test_resolve_real_svelte_file_wins_over_svelte_ts_sibling
      Existence check short-circuits before any extension append, so a
      real .svelte file always wins over a .svelte.ts sibling.
When both a file (foo.ts) and a directory (foo/) exist at the same path,
both TypeScript and Vite prefer the file. The previous ordering checked
directory first and fell through unchanged when the directory had no
index, silently dropping every import like 'from ./auth' when an
auth/ subdirectory existed alongside auth.ts.
Third call site that re-implemented the same .js→.ts rewrite in
isolation. Previously only handled the explicit .js→.ts case; bare
paths, multi-dot helper files, and alias-resolved dynamic imports
all dropped silently.

Now uses _resolve_js_module_path on both branches (relative and
alias) — same shape as the static-import and Svelte regex paths.

Real-world impact: TS files using `await import('./foo')` patterns
for code splitting (e.g. lazy-loading a profanity check) now produce
edges to the resolved target.
- Register .groovy and .gradle in CODE_EXTENSIONS, _DISPATCH, and collect_files
- Add _GROOVY_CONFIG (reuses Java import handler)
- Add regex-based _extract_spock_fallback for Spock spec files where
  tree-sitter-groovy wraps the body in ERROR nodes due to def-string methods
- _is_spock_file detects via regex scan (def "...") instead of node-label
  heuristic, avoiding false negatives on classes whose name differs from stem
- Fallback retains only file node + import edges from tree-sitter pass to
  prevent orphaned constructor/method nodes
- Add tree-sitter-groovy>=0.1.2 dependency
- Add 11 tests covering plain Groovy and Spock paths, including apostrophe
  in feature method names
Gemini is often the cheaper available quota for low-stakes semantic graph extraction, while OpenAI is a useful fallback. Extend the direct extraction backend registry, CLI validation, docs, and tests so headless extraction can use GEMINI_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY without changing the existing Claude and Kimi paths.

Constraint: Gemini supports OpenAI-compatible chat completions at the Google generative-language endpoint

Rejected: Native google-genai integration | higher dependency and response-shape churn for the same chat-completions path

Confidence: medium

Scope-risk: moderate

Directive: Keep backend detection explicit and test every accepted API-key environment variable before adding new providers

Tested: uv run --directory vendor/graphify pytest tests/test_llm_backends.py tests/test_chunking.py -q

Not-tested: Live Gemini/OpenAI API calls; no GEMINI_API_KEY or OPENAI_API_KEY present in this environment
`detect_incremental(root)` always called `detect(root)` without forwarding
the `follow_symlinks` kwarg. As a result, corpora that include symlinked
sub-trees pointing to directories outside the scan root (e.g. a
`state_of_truth/` symlink pointing at `~/.hermes/state_of_truth/`) were
visible to a full `detect()` run with `follow_symlinks=True` but invisible
to any subsequent `--update` run. The incremental scan would then either
report no changes (silently dropping legitimate new files) or repeatedly
re-extract a phantom subset, depending on what was reachable without
crossing symlinks.

Add a keyword-only `follow_symlinks` parameter to `detect_incremental()`
and forward it. Default stays `False` for backwards compatibility — only
callers that already opt in to symlink following on `detect()` pick up
the new behaviour for incremental runs too.

Test: a corpus with a symlinked directory is invisible with
`follow_symlinks=False`, fully indexed with `follow_symlinks=True`, and
correctly reports zero new files on a second incremental scan after the
manifest is saved.
The initial Gemini backend defaulted to 2.5 Flash, but large semantic extraction chunks can benefit from newer models and more output headroom. Move the default to Gemini 3 Flash Preview, add CLI and environment model overrides, and increase the Gemini completion budget while keeping low reasoning effort for cost control.

Constraint: Google exposes Gemini through an OpenAI-compatible chat-completions endpoint

Rejected: Hardcode Gemini 3.1 Pro as the default | higher cost for routine repository indexing

Confidence: medium

Scope-risk: narrow

Directive: Keep --model and GRAPHIFY_GEMINI_MODEL working before changing Gemini defaults again

Tested: uv run --directory vendor/graphify pytest tests/test_llm_backends.py tests/test_chunking.py -q

Not-tested: Live Gemini 3 extraction on the full cloud-edge repo before this commit
…s 16384, prune message clarity, svelte stub source_file, svelte static imports, manifest on full rebuild + pi skill YAML fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add `.luau` to CODE_EXTENSIONS and route it through extract_lua
(tree-sitter-lua). Roblox first-party code uses .luau; without this,
graphify silently skipped 379/479 files on a real Roblox codebase
and the resulting graph was dominated by vendored .lua dependencies.

tree-sitter-lua doesn't parse Luau type annotations, but it
successfully extracts function declarations and call edges from
Luau source — verified on a 379-file Roblox codebase (1265 nodes,
1471 edges, 236 communities).

A dedicated tree-sitter-luau grammar would be a richer long-term
fix; this is the minimal change to make Luau projects work today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Automated security fix generated by Orbis Security AI
safishamsi and others added 25 commits May 26, 2026 09:19
Keep Graphify query segmentation focused on Chinese terms: rename the CJK helpers and extra to Chinese scope, cache the optional jieba import at module load, and keep a bigram fallback when jieba is unavailable.

Constraint: Reviewer asked either to broaden Hiragana/Katakana/Hangul support or rename CJK helpers; user chose Chinese-only because Japanese segmentation accuracy is uncertain.

Rejected: Broaden to Japanese and Korean segmentation | jieba is Chinese-oriented and the user explicitly limited scope to Chinese.

Confidence: high

Scope-risk: narrow

Directive: Do not label this path as CJK unless Hiragana/Katakana/Hangul segmentation is intentionally supported and tested.

Tested: uv run --with pytest pytest tests/test_serve.py tests/test_query_cli.py tests/test_benchmark.py

Tested: uv run --with pytest --with jieba pytest tests/test_serve.py -k "chinese or non_chinese"

Tested: graphify update .

Not-tested: Full test suite.

Co-authored-by: OmX <omx@oh-my-codex.dev>
…r, .cshtml)

Adds extract_sln, extract_csproj, and extract_razor extractors. Captures NuGet
package refs, project-to-project dependencies, target frameworks, SDK attribute,
@using/@inject/@inherits/@model directives, Blazor component refs, and @code
methods. Resolves relative project paths to absolute paths so sln/csproj nodes
link correctly when the graph is assembled. Closes Graphify-Labs#515.

Co-Authored-By: aksrathore <aksrathore@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…olute paths

prune_set in build_merge now includes relative-path variants of each deleted file
so manifest absolute paths (e.g. /home/user/corpus/module_b/utils.py) match graph
node source_file values (e.g. module_b/utils.py) regardless of OS or run context.
Fixes Graphify-Labs#1007.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…for symlink safety

Replace inlined path normalisation with _norm_source_file (the same function
that builds node source_file keys) so prune_set and node attrs are normalised
identically. resolve() on root handles symlinked scan roots. Keep both raw and
normalised forms in prune_set so nodes with absolute source_file also match.
Add edge pruning and Windows backslash path tests per Opus review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…raph before eviction

Three issues in _rebuild_code (watch.py):
1. _relativize_source_files was called on result after eviction list was built,
   so existing nodes with absolute source_file were never normalized before comparison
2. deleted_paths and evict_sources used str() (backslashes on Windows) while
   graph.json stores forward-slash paths via _norm_source_file
3. _relativize_source_files itself used str() instead of as_posix()

Also fix extract.py source_file relativization to use as_posix(). Closes Graphify-Labs#1007.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…raphify-Labs#1028)

cluster-only re-runs Leiden clustering and then re-applies the existing
.graphify_labels.json by raw cid index, which causes labels to attach to
clusters whose members are unrelated to the label's original meaning
whenever the graph has changed between labeling and re-clustering.

Mirror the safety net already present in watch.py:_rebuild_code added in
Graphify-Labs#822 for the watch/update paths.

Adds a regression test that fails without the fix (label cids become
orphaned from graph.json community attributes after re-clustering).

Refs: Graphify-Labs#1027

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…p.json)

Adds graphify/mcp_ingest.py — extracts MCP server configurations into the
knowledge graph. Captures server nodes, NuGet/npm/pip package refs, commands,
env var requirements, and inter-server edges. Dispatched by filename before
the suffix lookup so generic .json extraction is unaffected. Env values are
discarded to prevent secret leakage. File size capped at 1 MiB. 29 tests.

Fixes: server_count budget now checked after validity guard so invalid entries
don't consume capacity; removed misleading uv run docstring example.

Co-Authored-By: adityachaudhary99 <adityachaudhary99@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… IDs to prevent machine-specific absolute paths in graph.json (Graphify-Labs#999)
…xtract_lpk

stdlib ET does not cap entity expansion — a crafted .csproj or .lpk with nested
internal entities can exhaust memory. Pre-screen input bytes for <!DOCTYPE and
<!ENTITY before parsing (legitimate MSBuild/Lazarus files never contain these).
Also adds the missing 2 MiB size cap to extract_lpk (csproj already had one).
No new dependencies required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_paths is None (Graphify-Labs#1007)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ify-Labs#1010)

graphify-out regenerates differently on every `graphify update` even
when no source changed, so the committed graph is perpetually dirty and
the post-commit/post-checkout hooks fight every commit. Two independent
nondeterminism sources, each fixed here:

1. Edge direction flips. build.py builds an undirected graph and stores
   direction in _src/_tgt; collapsing two edges onto the same node pair
   is last-write-wins, and unstable edge iteration order flips them
   run-to-run. Fixed by sorting edges by (source, target, relation)
   before the add loop.

2. Clustering churn. The networkx Louvain fallback iterates string-keyed
   sets whose order is randomized per-process by PYTHONHASHSEED, so
   community assignments differ run-to-run even with seed=42. Fixed by
   exporting PYTHONHASHSEED=0 in the generated post-commit and
   post-checkout hook scripts.

With both fixes, `graphify update` is idempotent: rebuilding an
already-converged graphify-out reproduces graph.json and GRAPH_REPORT.md
byte-for-byte.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…p platform, punctuation search, builtin god-node filter, .svh Verilog (Graphify-Labs#1040, Graphify-Labs#1018, Graphify-Labs#1037, Graphify-Labs#948, Graphify-Labs#994, Graphify-Labs#916, Graphify-Labs#1042)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ge, decorated method node ID mismatch (Graphify-Labs#1047, Graphify-Labs#1046, Graphify-Labs#1050)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…fy-Labs#1006)

When a graph exceeds the viz node limit, to_html() builds a
community-aggregated meta-graph and recursively calls itself.
The recursive call never carried hyperedges onto the meta-graph,
so graph.html always emitted const hyperedges = [] even when
graph.json contained plenty.

This fix remaps hyperedge node references from semantic node IDs
to community IDs before the recursive call, so hyperedge regions
render correctly in the aggregated view. Hyperedges that collapse
to fewer than 2 distinct communities are dropped (they wouldn't
render as a polygon anyway).

Fixes Graphify-Labs#1005
…aphify-Labs#884, Graphify-Labs#1030)

- Feat: extract_dm (tree-sitter-dm), extract_dmi (PNG icon states),
  extract_dmm (tile dict uses edges), extract_dmf (window/elem hierarchy)
  for .dm .dme .dmi .dmm .dmf; 26 tests, fixtures, pyproject.toml dep
- Feat: graphify extract --mode deep flag; deep_mode threaded through all
  four LLM backends via extract_corpus_parallel
- Fix: CHANGELOG 0.8.21 entries for Graphify-Labs#1050, Graphify-Labs#1046, Graphify-Labs#1047 that were missing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…_dmm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nflict

Two compounding bugs caused ~30-50% of semantic chunks to come back as
"hollow responses" when running `graphify extract --backend claude-cli`,
triggering adaptive bisection that doubled or tripled the number of
`claude -p` subprocesses.

1. `_parse_llm_json` only stripped markdown fences when `raw.startswith("```")`.
   Claude frequently prepends a short preamble ("Here are the extracted
   entities:\n\n```json\n{...}\n```") which made the check fail, so the
   fence-stripping was skipped entirely and json.loads dropped the chunk.

2. `_call_claude_cli` used `--append-system-prompt`, which adds graphify's
   extraction prompt on top of Claude Code's default interactive-agent
   prompt ("use markdown formatting", "output text to communicate with
   the user"). The conflicting instructions explain the preambles and
   fences from issue Graphify-Labs#1 — the default prompt won about half the time.

Fix:

- Robust _parse_llm_json: strip fences regardless of position, with a
  fallback that scans for the first balanced {...} object in the
  response. Handles preambles, trailing prose, and prose-wrapped JSON
  without fences. Diagnostic log on terminal failure shows the first
  200 chars of the response so future format drift is debuggable.

- Switch claude-cli to --system-prompt (replaces). Eliminates the
  conflict at the source. Subscription auth unaffected (verified).
  Side benefit: cache-creation tokens per call drop ~19% because
  Claude Code's default system prompt is no longer materialized.

The two fixes are complementary: Graphify-Labs#2 dramatically reduces malformed
responses; Graphify-Labs#1 keeps graphify robust against residual cases (soft
refusals, model confusion) and benefits every other backend.

Verified on a 43-file modes/ corpus (Markdown docs):
- Before: 30-50% hollow rate, multiple bisections, log full of errors
- After: 0 hollow responses, 0 bisections, clean log

Verified on a 800-file repo (career-ops, mixed code + docs):
- Before: ~44 min, 269k output tokens (inflated by preambles)
- After: ~3-4 min incremental, 19k output tokens (-93%)
@safishamsi

Copy link
Copy Markdown
Collaborator

Thanks for tracking down the claude-cli hollow-response issue — this is a real problem worth fixing. Unfortunately the PR is unreviewable in its current state: the diff shows 138,000+ additions because the branch is based off a stale main rather than v8. The actual fix is buried under a full re-fork of the repo.

Please:

  1. Re-target the branch to v8 (git rebase v8 or open a fresh branch from v8)
  2. Drop all unrelated files — the PR should only touch graphify/llm.py
  3. Add the unit tests described in the PR body (preamble/fence handling, the four test cases)
  4. Re-open when the diff is clean

Happy to merge once we can see what's actually changing.

claude-cli defaults to Opus, which is overkill for the structured
JSON extraction graphify performs. Setting GRAPHIFY_CLAUDE_CLI_MODEL=
haiku (or sonnet, or a full model ID) lets users pick a cheaper /
faster model for the semantic-extraction pass without touching the
default behaviour.

Validated on a small corpus with haiku-4-5:
- Output tokens: -82% vs Opus (1.3k vs 7.6k)
- Final graph identical (118 nodes / 192 edges)
- 10 hollow responses observed — recovered by the parser fixes
  earlier in this PR. Without those fixes, this commit would
  silently lose chunks.

The robust-parser commit and the --system-prompt commit are
prerequisites — Haiku markdown-wraps responses more often than
Opus, which is exactly the failure mode they handle.
@christophepub

Copy link
Copy Markdown
Contributor Author

Superseded by #1063 — re-targeted to v8 with clean diff and unit tests as requested. Apologies for the noisy first attempt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.