Skip to content

Commit b5048e4

Browse files
Aldominguez12claude
andcommitted
fix(images): write note-relative image links in sources pages
Short-doc source pages live at wiki/sources/<doc>.md but embedded their images with wiki-root-relative links (sources/images/<doc>/file.png). Markdown renderers resolve links relative to the containing file — Obsidian, GitHub, VS Code — so every image resolved to the non-existent wiki/sources/sources/images/... and rendered broken. - New md_image_ref() helper emits note-relative images/<doc>/... links, used by the three .md-visible writers (convert_pdf_with_images, extract_base64_images, copy_relative_images). - Long-doc JSON page metadata keeps wiki-root-relative paths: it is internal, consumed by get_wiki_page_content/read_wiki_image against the wiki root. Now documented explicitly in the docstrings. - read_wiki_image() retries note-relative paths under sources/, so agents can pass image paths verbatim as seen in either surface. - Query/skill-factory prompts and the openkb skill's wiki-schema.md updated to describe both path forms. Existing KBs keep their old-style links (the read_wiki_image fallback does not cover them in renderers); a migration helper for already ingested sources pages is left as a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bd9fe39 commit b5048e4

7 files changed

Lines changed: 165 additions & 26 deletions

File tree

openkb/agent/query.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@
3737
- PageIndex documents (doc_type: pageindex): use get_page_content(doc_name, pages)
3838
with tight page ranges. The summary shows document tree structure with page
3939
ranges to help you target. Never fetch the whole document.
40-
6. Source content may reference images (e.g. ![image](sources/images/doc/file.png)).
41-
Use the get_image tool to view them when needed.
40+
6. Source content may reference images. Short-doc .md pages link them
41+
note-relative (e.g. ![image](images/doc/file.png), resolved from
42+
wiki/sources/); long-doc JSON page metadata lists them wiki-root-relative
43+
(e.g. sources/images/doc/file.png). Pass either form as seen to the
44+
get_image tool — it accepts both.
4245
7. Synthesize a clear, concise, well-cited answer grounded in wiki content.
4346
4447
Answer based only on wiki content. Be concise.
@@ -81,7 +84,10 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
8184
you'd need to see to answer accurately.
8285
8386
Args:
84-
image_path: Image path relative to wiki root (e.g. 'sources/images/doc/p1_img1.png').
87+
image_path: Image path as it appears in the content — either
88+
wiki-root-relative ('sources/images/doc/p1_img1.png') or
89+
note-relative as used in sources/ .md pages
90+
('images/doc/p1_img1.png').
8591
"""
8692
result = read_wiki_image(image_path, wiki_root)
8793
if result["type"] == "image":

openkb/agent/tools.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ def read_wiki_image(path: str, wiki_root: str) -> dict:
147147
"""Read an image file from the wiki and return as base64 data URL.
148148
149149
Args:
150-
path: Image path relative to *wiki_root* (e.g. ``"sources/images/doc/p1_img1.png"``).
150+
path: Image path relative to *wiki_root*
151+
(e.g. ``"sources/images/doc/p1_img1.png"``), or note-relative
152+
as embedded in sources/ .md pages
153+
(``"images/doc/p1_img1.png"`` — retried under ``sources/``).
151154
wiki_root: Absolute path to the wiki root directory.
152155
153156
Returns:
@@ -161,7 +164,13 @@ def read_wiki_image(path: str, wiki_root: str) -> dict:
161164
if not full_path.is_relative_to(root):
162165
return {"type": "text", "text": "Access denied: path escapes wiki root."}
163166
if not full_path.exists():
164-
return {"type": "text", "text": f"Image not found: {path}"}
167+
# Source .md pages embed images note-relative ("images/<doc>/<file>",
168+
# resolved from wiki/sources/). Callers pass those verbatim — retry
169+
# under sources/ before failing.
170+
alt_path = (root / "sources" / path).resolve()
171+
if not (alt_path.is_relative_to(root) and alt_path.exists()):
172+
return {"type": "text", "text": f"Image not found: {path}"}
173+
full_path = alt_path
165174

166175
mime = _MIME_TYPES.get(full_path.suffix.lower(), "image/png")
167176
b64 = base64.b64encode(full_path.read_bytes()).decode()

openkb/images.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@
2323
_MIN_IMAGE_DIM = 32
2424

2525

26+
def md_image_ref(alt: str, doc_name: str, filename: str) -> str:
27+
"""Markdown image reference as written into ``wiki/sources/<doc>.md``.
28+
29+
Note-relative (``images/{doc}/{file}``): source pages live directly in
30+
``wiki/sources/``, so this resolves in every renderer that resolves links
31+
relative to the containing file (Obsidian, GitHub, VS Code). Internal
32+
metadata (the per-page JSON of long docs) keeps wiki-root-relative
33+
``sources/images/...`` paths instead — those are consumed by tools that
34+
resolve against the wiki root, not rendered from a note.
35+
"""
36+
return f"![{alt}](images/{doc_name}/{filename})"
37+
38+
2639
def extract_pdf_images(pdf_path: Path, doc_name: str, images_dir: Path) -> dict[int, list[str]]:
2740
"""Extract images from a PDF using pymupdf's dict-mode block iteration.
2841
@@ -31,7 +44,9 @@ def extract_pdf_images(pdf_path: Path, doc_name: str, images_dir: Path) -> dict[
3144
as PNG. This captures both embedded bitmaps *and* vector-rendered figures
3245
that ``get_images()`` would miss.
3346
34-
Returns a mapping of page_number (1-based) → list of relative image paths.
47+
Returns a mapping of page_number (1-based) → list of image paths. Paths
48+
are wiki-root-relative (``sources/images/...``) — internal metadata for
49+
tools that resolve against the wiki root, not note-rendered markdown.
3550
"""
3651
images_dir.mkdir(parents=True, exist_ok=True)
3752
page_images: dict[int, list[str]] = {}
@@ -77,7 +92,10 @@ def convert_pdf_to_pages(pdf_path: Path, doc_name: str, images_dir: Path) -> lis
7792
"""Convert a PDF to per-page dicts with text content and images.
7893
7994
Each dict has ``{"page": int, "content": str, "images": [{"path": str}]}``.
80-
Images are saved to *images_dir* and referenced with wiki-root-relative paths.
95+
Images are saved to *images_dir* and referenced with wiki-root-relative
96+
``sources/images/...`` paths — these pages land in ``sources/<doc>.json``
97+
(never rendered from a note), and both ``get_wiki_page_content`` and
98+
``read_wiki_image`` resolve them against the wiki root.
8199
"""
82100
images_dir.mkdir(parents=True, exist_ok=True)
83101
pages: list[dict] = []
@@ -134,8 +152,9 @@ def convert_pdf_with_images(pdf_path: Path, doc_name: str, images_dir: Path) ->
134152
"""Convert a PDF to markdown with inline images using pymupdf dict-mode.
135153
136154
Iterates blocks in reading order per page. Text blocks become text,
137-
image blocks are saved to disk and replaced with ``![image](path)``
138-
inline — preserving the original position in the document.
155+
image blocks are saved to disk and replaced with a note-relative
156+
``![image](images/{doc_name}/...)`` link inline — preserving the
157+
original position in the document.
139158
140159
Returns the full markdown string.
141160
"""
@@ -173,7 +192,7 @@ def convert_pdf_with_images(pdf_path: Path, doc_name: str, images_dir: Path) ->
173192
filename = f"p{page_num}_img{img_counter}.png"
174193
(images_dir / filename).write_bytes(pix.tobytes("png"))
175194
pix = None
176-
parts.append(f"\n![image](sources/images/{doc_name}/{filename})\n")
195+
parts.append(f"\n{md_image_ref('image', doc_name, filename)}\n")
177196
except Exception:
178197
logger.warning("Failed to save image block on page %d", page_num)
179198
return "\n".join(parts)
@@ -184,7 +203,7 @@ def extract_base64_images(markdown: str, doc_name: str, images_dir: Path) -> str
184203
185204
For each ``![alt](data:image/ext;base64,DATA)`` match:
186205
- Decode base64 bytes → save to ``images_dir/img_NNN.ext``
187-
- Replace the link with ``![alt](sources/images/{doc_name}/img_NNN.ext)``
206+
- Replace the link with ``![alt](images/{doc_name}/img_NNN.ext)``
188207
- On decode failure: log a warning and leave the original text unchanged.
189208
"""
190209
counter = 0
@@ -208,7 +227,7 @@ def extract_base64_images(markdown: str, doc_name: str, images_dir: Path) -> str
208227
images_dir.mkdir(parents=True, exist_ok=True)
209228
dest.write_bytes(image_bytes)
210229

211-
new_ref = f"![{alt}](sources/images/{doc_name}/{filename})"
230+
new_ref = md_image_ref(alt, doc_name, filename)
212231
result = result.replace(match.group(0), new_ref, 1)
213232

214233
return result
@@ -220,7 +239,7 @@ def copy_relative_images(markdown: str, source_dir: Path, doc_name: str, images_
220239
For each ``![alt](relative/path)`` match (skipping http/https and data URIs):
221240
- Resolve path relative to ``source_dir``
222241
- Copy to ``images_dir/{filename}``
223-
- Replace link with ``![alt](sources/images/{doc_name}/{filename})``
242+
- Replace link with ``![alt](images/{doc_name}/{filename})``
224243
- Missing source file: log a warning and leave the original text unchanged.
225244
"""
226245
result = markdown
@@ -254,7 +273,7 @@ def copy_relative_images(markdown: str, source_dir: Path, doc_name: str, images_
254273
images_dir.mkdir(parents=True, exist_ok=True)
255274
shutil.copy2(src, images_dir / filename)
256275

257-
new_ref = f"![{alt}](sources/images/{doc_name}/{filename})"
276+
new_ref = md_image_ref(alt, doc_name, filename)
258277
result = result.replace(match.group(0), new_ref, 1)
259278

260279
return result

openkb/skill/creator.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,10 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText:
105105
need to see in order to distil it correctly into the skill.
106106
107107
Args:
108-
image_path: Path relative to wiki/
109-
(e.g. ``"sources/images/doc/p1_img1.png"``).
108+
image_path: Image path as it appears in the content — either
109+
wiki-root-relative (``"sources/images/doc/p1_img1.png"``)
110+
or note-relative as used in sources/ .md pages
111+
(``"images/doc/p1_img1.png"``).
110112
"""
111113
result = _read_image_impl(image_path, wiki_root)
112114
if result["type"] == "image":

skills/openkb/references/wiki-schema.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,11 @@ thing, read the matching entity page first.
9999

100100
## `wiki/sources/<doc>.md` (short docs)
101101

102-
The markitdown-converted full text. Image refs appear as
103-
`![](sources/images/<doc>/p1_img1.png)`.
102+
The markitdown-converted full text. Image refs are note-relative —
103+
`![](images/<doc>/p1_img1.png)` — and resolve from `wiki/sources/`
104+
(the files live in `wiki/sources/images/<doc>/`). The per-page JSON of
105+
long docs instead lists images wiki-root-relative
106+
(`sources/images/<doc>/...`).
104107

105108
## `wiki/sources/<doc>.json` (long PDFs)
106109

tests/test_agent_tools.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,58 @@
77
list_wiki_files,
88
parse_pages,
99
read_wiki_file,
10+
read_wiki_image,
1011
write_wiki_file,
1112
)
1213

14+
FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8
15+
16+
17+
# ---------------------------------------------------------------------------
18+
# read_wiki_image
19+
# ---------------------------------------------------------------------------
20+
21+
22+
class TestReadWikiImage:
23+
def _make_image(self, tmp_path):
24+
images_dir = tmp_path / "sources" / "images" / "doc"
25+
images_dir.mkdir(parents=True)
26+
(images_dir / "p1_img1.png").write_bytes(FAKE_PNG)
27+
28+
def test_reads_wiki_root_relative_path(self, tmp_path):
29+
self._make_image(tmp_path)
30+
31+
result = read_wiki_image("sources/images/doc/p1_img1.png", str(tmp_path))
32+
33+
assert result["type"] == "image"
34+
assert result["image_url"].startswith("data:image/png;base64,")
35+
36+
def test_reads_note_relative_path_from_sources(self, tmp_path):
37+
# Source .md pages embed images as "images/<doc>/<file>" (relative to
38+
# wiki/sources/); the tool must resolve those verbatim too.
39+
self._make_image(tmp_path)
40+
41+
result = read_wiki_image("images/doc/p1_img1.png", str(tmp_path))
42+
43+
assert result["type"] == "image"
44+
assert result["image_url"].startswith("data:image/png;base64,")
45+
46+
def test_missing_image_reports_not_found(self, tmp_path):
47+
self._make_image(tmp_path)
48+
49+
result = read_wiki_image("images/doc/nope.png", str(tmp_path))
50+
51+
assert result["type"] == "text"
52+
assert "not found" in result["text"].lower()
53+
54+
def test_path_escape_denied(self, tmp_path):
55+
self._make_image(tmp_path)
56+
57+
result = read_wiki_image("../outside.png", str(tmp_path))
58+
59+
assert result["type"] == "text"
60+
assert "Access denied" in result["text"]
61+
1362
# ---------------------------------------------------------------------------
1463
# list_wiki_files
1564
# ---------------------------------------------------------------------------

tests/test_images.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def test_single_base64_image_extracted(self, tmp_path):
4141

4242
# Result should reference a saved file, not the raw base64
4343
assert "data:image/png;base64," not in result
44-
assert "![alt text](sources/images/doc/img_001.png)" == result
44+
assert "![alt text](images/doc/img_001.png)" == result
4545

4646
# File should exist on disk
4747
saved = images_dir / "img_001.png"
@@ -56,8 +56,8 @@ def test_multiple_base64_images_numbered_sequentially(self, tmp_path):
5656
md = f"![fig1](data:image/png;base64,{b64_png})\n![fig2](data:image/jpeg;base64,{b64_jpg})"
5757
result = extract_base64_images(md, "doc", images_dir)
5858

59-
assert "![fig1](sources/images/doc/img_001.png)" in result
60-
assert "![fig2](sources/images/doc/img_002.jpeg)" in result
59+
assert "![fig1](images/doc/img_001.png)" in result
60+
assert "![fig2](images/doc/img_002.jpeg)" in result
6161
assert (images_dir / "img_001.png").exists()
6262
assert (images_dir / "img_002.jpeg").exists()
6363

@@ -85,7 +85,7 @@ def test_mixed_valid_invalid_base64(self, tmp_path, caplog):
8585

8686
with caplog.at_level(logging.WARNING, logger="openkb.images"):
8787
result = extract_base64_images(md, "doc", images_dir)
88-
assert "![good](sources/images/doc/img_001.png)" in result
88+
assert "![good](images/doc/img_001.png)" in result
8989
assert f"data:image/png;base64,{bad}" in result
9090

9191

@@ -107,7 +107,7 @@ def test_existing_relative_image_copied_and_rewritten(self, tmp_path):
107107
md = "![diagram](diagram.png)"
108108
result = copy_relative_images(md, source_dir, "doc", images_dir)
109109

110-
assert "![diagram](sources/images/doc/diagram.png)" == result
110+
assert "![diagram](images/doc/diagram.png)" == result
111111
assert (images_dir / "diagram.png").read_bytes() == FAKE_PNG
112112

113113
def test_missing_relative_image_leaves_original(self, tmp_path, caplog):
@@ -157,8 +157,8 @@ def test_multiple_relative_images_all_copied(self, tmp_path):
157157
md = "![a](a.png)\n![b](b.jpg)"
158158
result = copy_relative_images(md, source_dir, "doc", images_dir)
159159

160-
assert "![a](sources/images/doc/a.png)" in result
161-
assert "![b](sources/images/doc/b.jpg)" in result
160+
assert "![a](images/doc/a.png)" in result
161+
assert "![b](images/doc/b.jpg)" in result
162162
assert (images_dir / "a.png").exists()
163163
assert (images_dir / "b.jpg").exists()
164164

@@ -195,4 +195,55 @@ def test_same_image_referenced_twice_is_copied_once(self, tmp_path):
195195
result = copy_relative_images(md, source_dir, "doc", images_dir)
196196

197197
assert [p.name for p in images_dir.iterdir()] == ["logo.png"]
198-
assert result.count("sources/images/doc/logo.png") == 2
198+
assert result.count("images/doc/logo.png") == 2
199+
200+
201+
# ---------------------------------------------------------------------------
202+
# Note-relative resolution (Obsidian / GitHub compatibility)
203+
# ---------------------------------------------------------------------------
204+
205+
206+
class TestNoteRelativeResolution:
207+
"""Generated ``![...](...)`` links must resolve relative to the note.
208+
209+
Source pages live at ``wiki/sources/<doc>.md`` and their images at
210+
``wiki/sources/images/<doc>/`` — renderers that resolve links relative
211+
to the containing file (Obsidian, GitHub, VS Code) must find the image.
212+
A wiki-root-relative link (``sources/images/...``) would resolve to the
213+
non-existent ``wiki/sources/sources/images/...`` from those notes.
214+
"""
215+
216+
@staticmethod
217+
def _link_paths(markdown: str) -> list[str]:
218+
import re
219+
220+
return re.findall(r"!\[[^\]]*\]\(([^)]+)\)", markdown)
221+
222+
def test_base64_image_ref_resolves_from_sources_note(self, tmp_path):
223+
wiki = tmp_path / "wiki"
224+
note = wiki / "sources" / "doc.md"
225+
images_dir = wiki / "sources" / "images" / "doc"
226+
images_dir.mkdir(parents=True)
227+
228+
md = f"![alt](data:image/png;base64,{_make_b64(FAKE_PNG)})"
229+
result = extract_base64_images(md, "doc", images_dir)
230+
note.write_text(result, encoding="utf-8")
231+
232+
for rel in self._link_paths(result):
233+
assert (note.parent / rel).exists(), rel
234+
235+
def test_copied_image_ref_resolves_from_sources_note(self, tmp_path):
236+
source_dir = tmp_path / "clip"
237+
source_dir.mkdir()
238+
(source_dir / "figure.png").write_bytes(FAKE_PNG)
239+
240+
wiki = tmp_path / "wiki"
241+
note = wiki / "sources" / "doc.md"
242+
images_dir = wiki / "sources" / "images" / "doc"
243+
images_dir.mkdir(parents=True)
244+
245+
result = copy_relative_images("![f](figure.png)", source_dir, "doc", images_dir)
246+
note.write_text(result, encoding="utf-8")
247+
248+
for rel in self._link_paths(result):
249+
assert (note.parent / rel).exists(), rel

0 commit comments

Comments
 (0)