Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) by KylinMountain · Pull Request #49 · VectifyAI/OpenKB · GitHub
Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) by KylinMountain · Pull Request #49 · VectifyAI/OpenKB · GitHub
Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) by KylinMountain · Pull Request #49 · VectifyAI/OpenKB · GitHub
Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) by KylinMountain · Pull Request #49 · VectifyAI/OpenKB · GitHub
Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) by KylinMountain · Pull Request #49 · VectifyAI/OpenKB · GitHub
Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) by KylinMountain · Pull Request #49 · VectifyAI/OpenKB · GitHub
Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) by KylinMountain · Pull Request #49 · VectifyAI/OpenKB · GitHub
Skip to content

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47) - #49

Merged
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks
May 15, 2026
Merged

fix: eliminate ghost wikilinks in LLM-generated wiki content (closes #47)#49
KylinMountain merged 4 commits into
mainfrom
fix/ghost-wikilinks

Conversation

@KylinMountain

Copy link
Copy Markdown
Collaborator

Summary

Closes#47. openkb lint was reporting 100+ broken [[wikilinks]] on every fresh ingest. The root cause is fully deterministic, not a legacy data artifact:

  • _compile_concepts plan stage caps create at 2–3 concepts per doc by design (anti-proliferation).
  • But _CONCEPT_PAGE_USER / _SUMMARY_USER told the LLM to "include wikilinks to related concepts" with no whitelist.
  • LLM bodies ended up with 5–10 wikilinks each, most pointing to concepts the plan never created → ghost links.

Empirical evidence: the same 5 documents (PDF + xlsx + docx + 2 markdown) on the same gpt-5.4-mini configuration produce 115 broken wikilinks across 30 unique ghost targets with the old code, 0 broken across 197 valid wikilinks with this PR.

Fix — three layers

Layer A — prompt whitelist._CONCEPT_PAGE_USER and _CONCEPT_UPDATE_USER now receive {known_targets} (existing concepts on disk + plan.create + plan.update + plan.related + summary for this doc) and instruct the LLM to use [[wikilinks]] only for targets in the list, writing plain text otherwise.

Layer B — in-memory deferred write + summary rewrite.compile_short_doc no longer writes the v1 summary to disk immediately; it's held in memory and used as cache context for plan + concept generation. After all bodies are generated:

  • Every concept body is run through strip_ghost_wikilinks (canonicalizes via NFKC + lowercase + _↔- normalization, drops unresolved links to plain text).
  • The LLM regenerates the summary with the same cache-friendly message structure (BP1 + BP2 hit) but constrained to the full whitelist, and the v2 summary is also stripped before being written.

Layer C — lint --fix.fix_broken_links applies the same strip_ghost_wikilinks against existing wiki content so users with already-broken KBs can clean them up in place.

Cache-control preserved

The summary-rewrite LLM call reuses the existing PR #38 cache breakpoints. Empirically:

  • summary-rewrite ... 6.8s (in=11887, out=869, cached=11008) on the PDF doc → 92% of input tokens are cache hits.
  • Marginal cost per doc: ~500–1000 fresh input tokens + ~1–2k output tokens (≈ 1/3 the cost of one concept body call).

Compatibility with Karpathy's spec

The Karpathy gist intentionally leaves the exact wikilink topology unspecified ("This document is intentionally abstract"), only requiring that cross-references exist and stay consistent. Summary pages keep their wikilinks — required for wiki-style navigation — but the links are now guaranteed to resolve.

Test plan

  • 19 new unit tests for strip_ghost_wikilinks and _normalize_target (case, underscore↔hyphen, NFKC, alias preservation, fuzzy rewrite, ghost-strip-to-plain).
  • All 251 tests pass (existing compiler + lint + cli tests unaffected).
  • End-to-end repro: fresh openkb init + openkb add raw/ on 5 mixed docs → openkb lint reports 0 broken across 197 wikilinks.
  • openkb lint --fix on the old broken KB cleaned 115 → 0 in one pass.

🤖 Generated with Claude Code

)
`openkb lint` was flagging 100+ broken [[wikilinks]] on every fresh
ingest. Investigation showed the issue is fully deterministic:
- `_compile_concepts` plan stage caps "create" at 2-3 concepts/doc
- but `_CONCEPT_PAGE_USER` and `_SUMMARY_USER` told the LLM to "include
wikilinks to related concepts" with no whitelist
- so concept/summary bodies ended up with 5-10 wikilinks each, most
pointing to concepts the plan never created → ghost links
Fixes in three layers:
1. Prompt whitelist (Layer A): `_CONCEPT_PAGE_USER` and
`_CONCEPT_UPDATE_USER` now receive `{known_targets}` (existing
concepts on disk + plan.create + plan.update + plan.related +
summary for this doc). The prompt instructs the LLM to use
[[wikilinks]] only for targets in the list and write plain text
otherwise.
2. In-memory deferred write + summary rewrite (Layer B):
`compile_short_doc` no longer writes the v1 summary to disk
immediately; it's held in memory and used as cache context. After
all concept bodies are generated, every body is run through
`strip_ghost_wikilinks` (canonicalizes via NFKC + lowercase + `_↔-`
normalization, drops unresolved links to plain text). Then the LLM
regenerates the summary with the same cache-friendly message
structure (BP1 + BP2 hit) but constrained to the whitelist, and
the v2 summary is also stripped before being written.
3. lint --fix (Layer C): `fix_broken_links` applies the same
strip_ghost_wikilinks against existing wiki content so users with
already-broken KBs can clean them up in place.
End-to-end validation:
- Same 5 documents (PDF + xlsx + docx + 2 markdown), same model
(gpt-5.4-mini): old code produced 115 broken wikilinks across 30
unique ghost targets; new code produces 0 broken across 197 valid
wikilinks.
- Cache-control optimization preserved: summary-rewrite call hits
92% cached tokens (e.g. 11008/11887 input cached on the PDF doc).
- 251 tests pass, including 19 new unit tests for `strip_ghost_wikilinks`
and `_normalize_target`.
Compatible with Karpathy's spec — the gist intentionally leaves the
exact wikilink topology unspecified, only requiring that
cross-references exist and stay consistent.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. The two early-return paths inside _compile_concepts write the raw v1 summary to disk when rewrite_summary=True — bypassing every layer of ghost-link defense the PR introduces. These are the exact LLM failure modes that produce the most ghost-laden v1 summaries (malformed plan JSON, or LLM deciding nothing needs to be created), so "fresh ingest produces 0 broken" only holds on the happy path. Either path triggering re-introduces the original bug.

Plan-parse-failure path:

parsed=_parse_json(plan_raw)
except (json.JSONDecodeError, ValueError) asexc:
logger.warning("Failed to parse concepts plan: %s", exc)
logger.debug("Raw: %s", plan_raw)
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return
# Fallback: if LLM returns a flat list, treat all items as "create"

Empty-plan path:

ifnotcreate_itemsandnotupdate_itemsandnotrelated_items:
ifrewrite_summary:
_write_summary(wiki_dir, doc_name, summary)
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
return

Suggested fix: before the _write_summary call in both branches, run the v1 summary through strip_ghost_wikilinks(summary, _list_existing_wiki_targets(wiki_dir)). The full per-round whitelist isn't available yet at those points (plan hasn't been parsed / is empty), but the on-disk set is a strict-but-correct fallback — it still catches all ghosts that don't exist anywhere in the wiki.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Follow-up to the ghost-wikilinks fix. Self-review on the prior commit
surfaced four resilience gaps in the new summary-rewrite path:
1. Plan-parse-fail / empty-plan fallback paths wrote the raw v1 summary
to disk without stripping. The exact LLM failure modes most likely
to produce ghost-heavy v1 summaries (malformed plan JSON, empty
plan) bypassed the whole Layer B defense.
2. If the rewrite LLM call returned an empty string, `_write_summary`
was called with "" and silently wiped the summary. The v1 was no
longer on disk (deferred write), so there was no fallback.
3. The rewrite call had no try/except. Any transient API failure
propagated out, leaving no summary file and no concept files
written despite all the LLM work being done.
4. `max_tokens=2048` could truncate long summary rewrites mid-sentence
while the original v1 summary call had no cap.
Fixes:
- Extract `_write_v1_summary_stripped()` closure inside `_compile_concepts`
that strips the v1 summary against `_list_existing_wiki_targets(wiki_dir)`
before writing — used by both early-return paths.
- Rewrite the summary-rewrite block to a try/except + empty-check
pattern: on exception or empty result, fall back to the v1 summary
stripped against the full whitelist. The summary is always written.
- Remove the `max_tokens=2048` cap (matches the v1 summary call).
- New `TestCompileShortDocFallbacks` class covers all three fallback
scenarios. `test_full_pipeline` now provides a third mock response
for the summary-rewrite call and asserts the rewrite content lands
on disk (the existing assertion would pass with garbage content
because `_mock_completion` clamps to the last response).
All 255 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

PR #49's compile-time defenses don't cover two other code paths that
write LLM-generated content into the wiki, both flagged by self-review:
- `openkb query --save` writes the agent's answer to wiki/explorations/
(cli.py). The query agent's instructions (via schema_md) encourage
[[wikilinks]] but the agent's view of which pages exist can drift
from disk reality.
- `/save` in the chat REPL writes the session transcript with the same
exposure (chat.py).
Both files land in wiki/explorations/, which lint.py scans for broken
links. So ghost wikilinks emitted by the agent end up reported as
broken links on the next `openkb lint` run, even after PR #49.
Promote `_list_existing_wiki_targets` to `lint.list_existing_wiki_targets`
(public, paired with `strip_ghost_wikilinks`) so it can be reused outside
the compile pipeline. The two save paths now strip the agent output
against the on-disk target set before writing. User-typed turns in the
chat transcript are preserved verbatim — only assistant responses are
filtered, since intentional user input shouldn't be auto-rewritten.
Tests:
- `TestQuerySaveGhostStrip` exercises `openkb query --save` with mixed
valid + ghost wikilinks in the mocked answer.
- `test_save_transcript_strips_ghost_wikilinks` covers the chat path,
also asserting user turn content is preserved.
- 257 tests pass.
@KylinMountain

Copy link
Copy Markdown
CollaboratorAuthor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

…ex, fix update prompt
Three follow-ups from the perf/design self-review. None of them gated
correctness but they sharpen cost and clarity:
1. **Cache the wikilink whitelist (BP3).** Previously every concept
generation call and the summary-rewrite call injected the full
`{known_targets}` list (5–10k tokens for a 500-concept KB) into
the per-call user prompt — past BP2 — so the whitelist was billed
on every request. Hoist it into a new cached user message
(`known_targets_msg`) sitting between `summary_msg` and the
per-call user turn, marked with `cache_control: ephemeral`. Plan
call deliberately omits it (whitelist isn't known yet at plan
time, and plan uses `concept_briefs` instead).
Cache structure is now: BP1 = doc, BP2 = summary, BP3 = whitelist.
Concept and rewrite calls reuse all three; only the trailing
per-concept (or per-rewrite) prompt is fresh.
The new `_KNOWN_TARGETS_USER` prompt lives in one place; the
`_CONCEPT_PAGE_USER` / `_CONCEPT_UPDATE_USER` / `_SUMMARY_REWRITE_USER`
templates lost their `{known_targets}` slot and now reference
"the whitelist message above". Test coverage updated to verify
the BP3 marker on both concept and rewrite calls.
2. **Reuse the normalized target index across loops.** `strip_ghost_wikilinks`
used to rebuild `norm_index = {_normalize_target(t): t for t in known_targets}`
on every call. `fix_broken_links` (lint --fix) scanned N files with the
same `known_targets`, paying the O(M) rebuild N times — measurable
on large KBs. Same shape for `_save_transcript` on a long chat.
Expose `build_norm_index(known_targets)` as a small public helper,
add a keyword-only `norm_index` parameter to `strip_ghost_wikilinks`,
and pre-build the index once in `fix_broken_links` and
`_save_transcript`. Behavior is identical when the parameter is
omitted, so all existing callers are unchanged.
3. **Resolve the `_CONCEPT_UPDATE_USER` instruction contradiction.**
The update prompt said "Maintain existing [[wikilinks]] and add new
ones where appropriate" immediately followed by "MUST link only to
targets in this whitelist". For pages with legacy ghost links, this
was a contradictory directive — the strip safety net caught the
resulting waste but the prompt itself was unclear. Reword to make
the whitelist the unconditional rule, with "preserve structure and
intent" as the orthogonal instruction.
260 tests pass (3 new: prebuilt-norm_index identity test, build_norm_index
unit tests). The existing cache-control test is extended to assert BP3
on both concept-generation and summary-rewrite calls.
Sign up for freeto 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.

A lot of broken links were found.

1 participant

@KylinMountain