Skip to content

Commit 4b891fa

Browse files
committed
feat: bidirectional backlinks between summaries and concepts
- Add _backlink_summary: ensures summary pages link to all related concepts - Add _backlink_concepts: ensures concept pages link back to source summaries - _update_index auto-creates index.md if missing - Both merge into existing sections instead of duplicating
1 parent 1a28c11 commit 4b891fa

2 files changed

Lines changed: 174 additions & 1 deletion

File tree

openkb/agent/compiler.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,11 +351,68 @@ def _add_related_link(wiki_dir: Path, concept_slug: str, doc_name: str, source_f
351351
path.write_text(text, encoding="utf-8")
352352

353353

354+
def _backlink_summary(wiki_dir: Path, doc_name: str, concept_slugs: list[str]) -> None:
355+
"""Append missing concept wikilinks to the summary page (no LLM call).
356+
357+
After all concepts are generated, this ensures the summary page links
358+
back to every related concept — closing the bidirectional link that
359+
concept pages already have toward the summary.
360+
361+
If a ``## Related Concepts`` section already exists, new links are
362+
appended into it rather than creating a duplicate section.
363+
"""
364+
summary_path = wiki_dir / "summaries" / f"{doc_name}.md"
365+
if not summary_path.exists():
366+
return
367+
368+
text = summary_path.read_text(encoding="utf-8")
369+
missing = [slug for slug in concept_slugs if f"[[concepts/{slug}]]" not in text]
370+
if not missing:
371+
return
372+
373+
new_links = "\n".join(f"- [[concepts/{s}]]" for s in missing)
374+
if "## Related Concepts" in text:
375+
# Append into existing section
376+
text = text.replace("## Related Concepts\n", f"## Related Concepts\n{new_links}\n", 1)
377+
else:
378+
text += f"\n\n## Related Concepts\n{new_links}\n"
379+
summary_path.write_text(text, encoding="utf-8")
380+
381+
382+
def _backlink_concepts(wiki_dir: Path, doc_name: str, concept_slugs: list[str]) -> None:
383+
"""Append missing summary wikilink to each concept page (no LLM call).
384+
385+
Ensures every concept page links back to the source document's summary,
386+
regardless of whether the LLM included the link in its output.
387+
388+
If a ``## Related Documents`` section already exists, the link is
389+
appended into it rather than creating a duplicate section.
390+
"""
391+
link = f"[[summaries/{doc_name}]]"
392+
concepts_dir = wiki_dir / "concepts"
393+
394+
for slug in concept_slugs:
395+
path = concepts_dir / f"{slug}.md"
396+
if not path.exists():
397+
continue
398+
text = path.read_text(encoding="utf-8")
399+
if link in text:
400+
continue
401+
if "## Related Documents" in text:
402+
text = text.replace("## Related Documents\n", f"## Related Documents\n- {link}\n", 1)
403+
else:
404+
text += f"\n\n## Related Documents\n- {link}\n"
405+
path.write_text(text, encoding="utf-8")
406+
407+
354408
def _update_index(wiki_dir: Path, doc_name: str, concept_names: list[str]) -> None:
355409
"""Append document and concept entries to index.md."""
356410
index_path = wiki_dir / "index.md"
357411
if not index_path.exists():
358-
return
412+
index_path.write_text(
413+
"# Knowledge Base Index\n\n## Documents\n\n## Concepts\n\n## Explorations\n",
414+
encoding="utf-8",
415+
)
359416

360417
text = index_path.read_text(encoding="utf-8")
361418

@@ -503,6 +560,12 @@ async def _gen_update(concept: dict) -> tuple[str, str, bool]:
503560
for slug in related_items:
504561
_add_related_link(wiki_dir, slug, doc_name, source_file)
505562

563+
# --- Step 3c: Backlink — summary ↔ concepts (code only) ---
564+
all_concept_slugs = concept_names + [s for s in related_items]
565+
if all_concept_slugs:
566+
_backlink_summary(wiki_dir, doc_name, all_concept_slugs)
567+
_backlink_concepts(wiki_dir, doc_name, all_concept_slugs)
568+
506569
# --- Step 4: Update index (code only) ---
507570
_update_index(wiki_dir, doc_name, concept_names)
508571

tests/test_compiler.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
_read_wiki_context,
1919
_read_concept_briefs,
2020
_add_related_link,
21+
_backlink_summary,
22+
_backlink_concepts,
2123
)
2224

2325

@@ -207,6 +209,114 @@ def test_sorted_alphabetically(self, tmp_path):
207209
assert slugs == ["apple", "mango", "zebra"]
208210

209211

212+
class TestBacklinkSummary:
213+
def test_adds_missing_concept_links(self, tmp_path):
214+
wiki = tmp_path / "wiki"
215+
summaries = wiki / "summaries"
216+
summaries.mkdir(parents=True)
217+
(summaries / "paper.md").write_text(
218+
"---\nsources: [paper.pdf]\n---\n\n# Summary\n\nContent about attention.",
219+
encoding="utf-8",
220+
)
221+
_backlink_summary(wiki, "paper", ["attention", "transformer"])
222+
text = (summaries / "paper.md").read_text()
223+
assert "[[concepts/attention]]" in text
224+
assert "[[concepts/transformer]]" in text
225+
226+
def test_skips_already_linked(self, tmp_path):
227+
wiki = tmp_path / "wiki"
228+
summaries = wiki / "summaries"
229+
summaries.mkdir(parents=True)
230+
(summaries / "paper.md").write_text(
231+
"---\nsources: [paper.pdf]\n---\n\n# Summary\n\nSee [[concepts/attention]].",
232+
encoding="utf-8",
233+
)
234+
_backlink_summary(wiki, "paper", ["attention", "transformer"])
235+
text = (summaries / "paper.md").read_text()
236+
# attention already linked, should not duplicate
237+
assert text.count("[[concepts/attention]]") == 1
238+
# transformer should be added
239+
assert "[[concepts/transformer]]" in text
240+
241+
def test_no_op_when_all_linked(self, tmp_path):
242+
wiki = tmp_path / "wiki"
243+
summaries = wiki / "summaries"
244+
summaries.mkdir(parents=True)
245+
original = "# Summary\n\n[[concepts/attention]] and [[concepts/transformer]]"
246+
(summaries / "paper.md").write_text(original, encoding="utf-8")
247+
_backlink_summary(wiki, "paper", ["attention", "transformer"])
248+
assert (summaries / "paper.md").read_text() == original
249+
250+
def test_skips_if_file_missing(self, tmp_path):
251+
wiki = tmp_path / "wiki"
252+
wiki.mkdir()
253+
# Should not raise
254+
_backlink_summary(wiki, "nonexistent", ["attention"])
255+
256+
def test_merges_into_existing_section(self, tmp_path):
257+
"""Second add should merge into existing ## Related Concepts, not duplicate."""
258+
wiki = tmp_path / "wiki"
259+
summaries = wiki / "summaries"
260+
summaries.mkdir(parents=True)
261+
(summaries / "paper.md").write_text(
262+
"# Summary\n\nContent.\n\n## Related Concepts\n- [[concepts/attention]]\n",
263+
encoding="utf-8",
264+
)
265+
_backlink_summary(wiki, "paper", ["attention", "transformer"])
266+
text = (summaries / "paper.md").read_text()
267+
assert text.count("## Related Concepts") == 1
268+
assert "[[concepts/transformer]]" in text
269+
assert text.count("[[concepts/attention]]") == 1
270+
271+
272+
class TestBacklinkConcepts:
273+
def test_adds_summary_link_to_concept(self, tmp_path):
274+
wiki = tmp_path / "wiki"
275+
concepts = wiki / "concepts"
276+
concepts.mkdir(parents=True)
277+
(concepts / "attention.md").write_text(
278+
"---\nsources: [paper.pdf]\n---\n\n# Attention\n\nContent.",
279+
encoding="utf-8",
280+
)
281+
_backlink_concepts(wiki, "paper", ["attention"])
282+
text = (concepts / "attention.md").read_text()
283+
assert "[[summaries/paper]]" in text
284+
assert "## Related Documents" in text
285+
286+
def test_skips_if_already_linked(self, tmp_path):
287+
wiki = tmp_path / "wiki"
288+
concepts = wiki / "concepts"
289+
concepts.mkdir(parents=True)
290+
(concepts / "attention.md").write_text(
291+
"# Attention\n\nBased on [[summaries/paper]].",
292+
encoding="utf-8",
293+
)
294+
_backlink_concepts(wiki, "paper", ["attention"])
295+
text = (concepts / "attention.md").read_text()
296+
assert text.count("[[summaries/paper]]") == 1
297+
assert "## Related Documents" not in text
298+
299+
def test_merges_into_existing_section(self, tmp_path):
300+
wiki = tmp_path / "wiki"
301+
concepts = wiki / "concepts"
302+
concepts.mkdir(parents=True)
303+
(concepts / "attention.md").write_text(
304+
"# Attention\n\n## Related Documents\n- [[summaries/old-paper]]\n",
305+
encoding="utf-8",
306+
)
307+
_backlink_concepts(wiki, "new-paper", ["attention"])
308+
text = (concepts / "attention.md").read_text()
309+
assert text.count("## Related Documents") == 1
310+
assert "[[summaries/old-paper]]" in text
311+
assert "[[summaries/new-paper]]" in text
312+
313+
def test_skips_missing_concept_file(self, tmp_path):
314+
wiki = tmp_path / "wiki"
315+
(wiki / "concepts").mkdir(parents=True)
316+
# Should not raise
317+
_backlink_concepts(wiki, "paper", ["nonexistent"])
318+
319+
210320
class TestAddRelatedLink:
211321
def test_adds_see_also_link(self, tmp_path):
212322
wiki = tmp_path / "wiki"

0 commit comments

Comments
 (0)