Skip to content

Commit 47e5ec2

Browse files
committed
fix(url-ingest): address PR #55 self-review findings
- _unique_path() prevents silent overwrites in raw/ when two URLs sanitize to the same filename (applied at both PDF and HTML write sites) - PDF filename now derives from response.geturl() (the post-redirect URL), so DOI/shortlink resolvers produce sensible names - add_single_file() returns bool; URL branch unlinks the fetched raw/* file when the downstream add is skipped (dedup), preventing orphan files that re-resurrect on every retry
1 parent 829f44f commit 47e5ec2

3 files changed

Lines changed: 243 additions & 14 deletions

File tree

openkb/cli.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,21 @@ def _find_kb_dir(override: Path | None = None) -> Path | None:
130130
return None
131131

132132

133-
def add_single_file(file_path: Path, kb_dir: Path) -> None:
133+
def add_single_file(file_path: Path, kb_dir: Path) -> bool:
134134
"""Convert, index, and compile a single document into the knowledge base.
135135
136136
Steps:
137137
1. Load config to get the model name.
138138
2. Convert the document (hash-check; skip if already known).
139139
3. If long doc: run PageIndex then compile_long_doc.
140140
4. Else: compile_short_doc.
141+
142+
Returns:
143+
True if the file was newly indexed and compiled. False when the
144+
file was skipped as a duplicate (hash already registered) or
145+
when any pipeline stage failed — callers that need to clean up
146+
the source file (URL-ingest path, for example) can act on a
147+
``False`` return.
141148
"""
142149
from openkb.agent.compiler import compile_long_doc, compile_short_doc
143150
from openkb.state import HashRegistry
@@ -156,11 +163,11 @@ def add_single_file(file_path: Path, kb_dir: Path) -> None:
156163
except Exception as exc:
157164
click.echo(f" [ERROR] Conversion failed: {exc}")
158165
logger.debug("Conversion traceback:", exc_info=True)
159-
return
166+
return False
160167

161168
if result.skipped:
162169
click.echo(f" [SKIP] Already in knowledge base: {file_path.name}")
163-
return
170+
return False
164171

165172
doc_name = file_path.stem
166173
index_result = None # populated only on the long-doc branch
@@ -174,7 +181,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> None:
174181
except Exception as exc:
175182
click.echo(f" [ERROR] Indexing failed: {exc}")
176183
logger.debug("Indexing traceback:", exc_info=True)
177-
return
184+
return False
178185

179186
summary_path = kb_dir / "wiki" / "summaries" / f"{doc_name}.md"
180187
click.echo(f" Compiling long doc (doc_id={index_result.doc_id})...")
@@ -192,7 +199,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> None:
192199
else:
193200
click.echo(f" [ERROR] Compilation failed: {exc}")
194201
logger.debug("Compilation traceback:", exc_info=True)
195-
return
202+
return False
196203
else:
197204
click.echo(f" Compiling short doc...")
198205
for attempt in range(2):
@@ -206,7 +213,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> None:
206213
else:
207214
click.echo(f" [ERROR] Compilation failed: {exc}")
208215
logger.debug("Compilation traceback:", exc_info=True)
209-
return
216+
return False
210217

211218
# Register hash only after successful compilation
212219
if result.file_hash:
@@ -225,6 +232,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> None:
225232

226233
append_log(kb_dir / "wiki", "ingest", file_path.name)
227234
click.echo(f" [OK] {file_path.name} added to knowledge base.")
235+
return True
228236

229237

230238
# ---------------------------------------------------------------------------
@@ -408,15 +416,22 @@ def add(ctx, path):
408416
click.echo("No knowledge base found. Run `openkb init` first.")
409417
return
410418

411-
# URL ingest: download into raw/ first, then fall through to the
412-
# normal file-path branch below. fetch_url_to_raw returns the
413-
# local path it wrote, or None on failure.
419+
# URL ingest: download into raw/ first, then call add_single_file
420+
# explicitly so we can clean up the just-downloaded file if it
421+
# turns out to be a duplicate (registry already has its hash).
422+
# Without this, re-adding the same URL leaves an orphan in raw/
423+
# that the registry can't reach via openkb remove.
414424
from openkb.url_ingest import looks_like_url, fetch_url_to_raw
415425
if looks_like_url(path):
416426
fetched = fetch_url_to_raw(path, kb_dir)
417427
if fetched is None:
418428
return
419-
path = str(fetched)
429+
added = add_single_file(fetched, kb_dir)
430+
if not added:
431+
# Duplicate (or failed mid-pipeline) — drop the just-fetched
432+
# file so raw/ doesn't accumulate orphans across retries.
433+
fetched.unlink(missing_ok=True)
434+
return
420435

421436
target = Path(path)
422437
if not target.exists():

openkb/url_ingest.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,28 @@ def _pdf_filename(url: str, content_disposition: str | None) -> str:
126126
return _sanitize_filename(last or "document", ".pdf")
127127

128128

129+
def _unique_path(target: Path) -> Path:
130+
"""Return ``target`` if it doesn't exist yet, otherwise append ``_2``,
131+
``_3``, … to the stem until an unused name is found.
132+
133+
Prevents silent overwrites in ``raw/`` when two different URLs
134+
sanitize to the same filename (e.g. two blog posts both titled
135+
"Introduction" → both ``Introduction.md``).
136+
"""
137+
if not target.exists():
138+
return target
139+
stem = target.stem
140+
suffix = target.suffix
141+
parent = target.parent
142+
for i in range(2, 10_000):
143+
candidate = parent / f"{stem}_{i}{suffix}"
144+
if not candidate.exists():
145+
return candidate
146+
raise RuntimeError(
147+
f"Could not find a free filename for {target} after 10k attempts"
148+
)
149+
150+
129151
def _download_pdf_chunked(response, head_bytes: bytes, target: Path) -> None:
130152
"""Write the already-read ``head_bytes`` plus the remaining streamed
131153
body to ``target``. Chunked so very large PDFs (50+ MB) don't sit in
@@ -179,7 +201,10 @@ def _extract_html(url: str, raw_dir: Path) -> Path | None:
179201
metadata = trafilatura.extract_metadata(raw_html)
180202
title = (metadata.title if metadata else None) or url
181203
filename = _sanitize_filename(title, ".md")
182-
target = raw_dir / filename
204+
# Pick a non-colliding name — two blog posts titled "Introduction"
205+
# would otherwise overwrite each other in raw/ and leave the hash
206+
# registry pointing at stale bytes.
207+
target = _unique_path(raw_dir / filename)
183208
target.write_text(markdown, encoding="utf-8")
184209
click.echo(
185210
f" Extracted: {title!r}\n"
@@ -228,13 +253,20 @@ def fetch_url_to_raw(url: str, kb_dir: Path) -> Path | None:
228253
actual = _sniff_content_type(head_bytes, declared)
229254

230255
if actual == "pdf":
256+
# Derive the filename from the *post-redirect* URL — urllib
257+
# follows redirects by default, so the user-typed URL may
258+
# not be the URL that actually served the bytes (DOI / short
259+
# link resolvers, mirror redirects, etc.). Falls back to the
260+
# original input when the response doesn't expose a final
261+
# URL.
262+
final_url = response.geturl() or url
231263
filename = _pdf_filename(
232-
url, response.headers.get("Content-Disposition"),
264+
final_url, response.headers.get("Content-Disposition"),
233265
)
234-
target = raw_dir / filename
266+
target = _unique_path(raw_dir / filename)
235267
_download_pdf_chunked(response, head_bytes, target)
236268
size_mb = target.stat().st_size / (1024 * 1024)
237-
click.echo(f" Saved: raw/{filename} ({size_mb:.1f} MB PDF)")
269+
click.echo(f" Saved: raw/{target.name} ({size_mb:.1f} MB PDF)")
238270
return target
239271

240272
if actual == "html":

tests/test_url_ingest.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
_pdf_filename,
1111
_sanitize_filename,
1212
_sniff_content_type,
13+
_unique_path,
1314
fetch_url_to_raw,
1415
looks_like_url,
1516
)
@@ -182,6 +183,9 @@ def get(self, key, default=None):
182183
# read(N) returns chunks; read() with no arg returns rest
183184
stream = io.BytesIO(body)
184185
resp.read = stream.read
186+
# urllib's HTTPResponse exposes geturl(); default to empty string so
187+
# callers using `response.geturl() or url` fall through to the input URL.
188+
resp.geturl = lambda: ""
185189
resp.__enter__ = lambda self: resp
186190
resp.__exit__ = lambda *a: None
187191
return resp
@@ -359,3 +363,181 @@ def test_fetch_network_error_returns_none(tmp_path, capsys):
359363
assert result is None
360364
err = capsys.readouterr().err
361365
assert "Network error" in err
366+
367+
368+
# ---------------------------------------------------------------------------
369+
# Self-review fixes from PR #55 review pass
370+
# ---------------------------------------------------------------------------
371+
372+
373+
def test_unique_path_returns_target_when_free(tmp_path):
374+
p = tmp_path / "foo.pdf"
375+
assert _unique_path(p) == p
376+
377+
378+
def test_unique_path_finds_next_free_slot(tmp_path):
379+
"""_2 / _3 / … must be appended to the stem (not the suffix) and
380+
must keep probing until a free name is found."""
381+
(tmp_path / "foo.pdf").write_bytes(b"a")
382+
(tmp_path / "foo_2.pdf").write_bytes(b"b")
383+
384+
result = _unique_path(tmp_path / "foo.pdf")
385+
assert result == tmp_path / "foo_3.pdf"
386+
387+
388+
def test_unique_path_handles_no_suffix(tmp_path):
389+
"""Files without an extension still get a usable suffix."""
390+
(tmp_path / "README").write_text("x")
391+
result = _unique_path(tmp_path / "README")
392+
assert result.name == "README_2"
393+
394+
395+
def test_fetch_pdf_picks_unique_name_when_target_exists(tmp_path):
396+
"""Two URLs that sanitize to the same filename must NOT silently
397+
overwrite — the second one gets `_2` appended."""
398+
raw_dir = tmp_path / "raw"
399+
raw_dir.mkdir()
400+
(raw_dir / "paper.pdf").write_bytes(b"%PDF-existing\nfirst")
401+
402+
body = b"%PDF-1.7\nsecond URL content"
403+
resp = _fake_response(body=body, headers={"Content-Type": "application/pdf"})
404+
# Make response.geturl mimic real urllib (returns the input URL when
405+
# there's no redirect). The fake response builder doesn't set this.
406+
resp.geturl = lambda: "https://mirror.example.com/paper.pdf"
407+
408+
with patch("urllib.request.urlopen", return_value=resp):
409+
result = fetch_url_to_raw("https://mirror.example.com/paper.pdf", tmp_path)
410+
411+
# First file is untouched, second went to paper_2.pdf
412+
assert (raw_dir / "paper.pdf").read_bytes() == b"%PDF-existing\nfirst"
413+
assert result == raw_dir / "paper_2.pdf"
414+
assert result.read_bytes() == body
415+
416+
417+
def test_fetch_html_picks_unique_name_when_target_exists(tmp_path):
418+
"""Two blog posts both titled 'Introduction' must NOT collide."""
419+
raw_dir = tmp_path / "raw"
420+
raw_dir.mkdir()
421+
(raw_dir / "Introduction.md").write_text("first blog post body")
422+
423+
resp = _fake_response(body=b"<html>", headers={"Content-Type": "text/html"})
424+
425+
second_md = "# Introduction\n\nA completely different second blog post body. " * 10
426+
fake_meta = MagicMock()
427+
fake_meta.title = "Introduction"
428+
429+
with patch("urllib.request.urlopen", return_value=resp), \
430+
patch("trafilatura.fetch_url", return_value="<html>...</html>"), \
431+
patch("trafilatura.extract", return_value=second_md), \
432+
patch("trafilatura.extract_metadata", return_value=fake_meta):
433+
result = fetch_url_to_raw("https://blog2.example.com/post", tmp_path)
434+
435+
assert (raw_dir / "Introduction.md").read_text() == "first blog post body"
436+
assert result == raw_dir / "Introduction_2.md"
437+
assert result.read_text() == second_md
438+
439+
440+
def test_fetch_pdf_uses_post_redirect_url_for_filename(tmp_path):
441+
"""When urllib follows a redirect (DOI → publisher CDN, short URLs,
442+
etc.), the filename must be derived from the final URL — not the
443+
user's original input — when the response has no Content-Disposition
444+
to override either."""
445+
body = b"%PDF-1.7\n..."
446+
resp = _fake_response(
447+
body=body,
448+
headers={"Content-Type": "application/pdf"}, # NO Content-Disposition
449+
)
450+
# urllib's HTTPResponse.geturl() returns the post-redirect URL
451+
resp.geturl = lambda: "https://publisher.example.com/articles/2024/great-paper.pdf"
452+
453+
with patch("urllib.request.urlopen", return_value=resp):
454+
result = fetch_url_to_raw("https://doi.org/10.1234/abc", tmp_path)
455+
456+
# Filename comes from the redirected URL's basename, not "abc"
457+
assert result is not None
458+
assert result.name == "great-paper.pdf"
459+
460+
461+
def test_add_single_file_returns_true_on_success(tmp_path):
462+
"""The new bool return contract: True when the file was actually
463+
indexed. Used by the URL-ingest cleanup path to decide whether the
464+
just-downloaded file in raw/ should be unlinked."""
465+
from openkb.cli import add_single_file
466+
from openkb.converter import ConvertResult
467+
468+
# Build a minimal KB scaffold
469+
(tmp_path / ".openkb").mkdir()
470+
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n")
471+
(tmp_path / ".openkb" / "hashes.json").write_text("{}")
472+
(tmp_path / "raw").mkdir()
473+
(tmp_path / "wiki" / "summaries").mkdir(parents=True)
474+
(tmp_path / "wiki" / "sources").mkdir(parents=True)
475+
(tmp_path / "wiki" / "concepts").mkdir(parents=True)
476+
(tmp_path / "wiki" / "log.md").write_text("")
477+
478+
doc = tmp_path / "raw" / "x.md"
479+
doc.write_text("# Hello")
480+
source_path = tmp_path / "wiki" / "sources" / "x.md"
481+
source_path.write_text("# Hello converted")
482+
483+
mock_result = ConvertResult(
484+
raw_path=doc, source_path=source_path,
485+
is_long_doc=False, file_hash="cafe" * 16,
486+
)
487+
488+
with patch("openkb.cli.convert_document", return_value=mock_result), \
489+
patch("openkb.cli.asyncio.run"):
490+
added = add_single_file(doc, tmp_path)
491+
492+
assert added is True
493+
494+
495+
def test_add_single_file_returns_false_on_skip(tmp_path):
496+
from openkb.cli import add_single_file
497+
from openkb.converter import ConvertResult
498+
499+
(tmp_path / ".openkb").mkdir()
500+
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n")
501+
(tmp_path / ".openkb" / "hashes.json").write_text("{}")
502+
(tmp_path / "raw").mkdir()
503+
doc = tmp_path / "raw" / "x.md"
504+
doc.write_text("# Hello")
505+
506+
skipped = ConvertResult(skipped=True)
507+
with patch("openkb.cli.convert_document", return_value=skipped):
508+
added = add_single_file(doc, tmp_path)
509+
510+
assert added is False
511+
512+
513+
def test_url_ingest_cleans_up_orphan_on_dedup_skip(tmp_path, monkeypatch):
514+
"""End-to-end: when the URL-fetched file is already in the registry,
515+
add_single_file returns False and the CLI must unlink it from raw/
516+
so the user doesn't accumulate untracked duplicates."""
517+
from click.testing import CliRunner
518+
from openkb.cli import cli
519+
from openkb.converter import ConvertResult
520+
521+
# Minimal KB
522+
(tmp_path / ".openkb").mkdir()
523+
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n")
524+
(tmp_path / ".openkb" / "hashes.json").write_text("{}")
525+
(tmp_path / "raw").mkdir()
526+
527+
# Fake the URL fetch — write directly to where url_ingest would
528+
fetched_path = tmp_path / "raw" / "paper.pdf"
529+
fetched_path.write_bytes(b"%PDF-fake")
530+
531+
runner = CliRunner()
532+
# fetch_url_to_raw is lazy-imported inside `add`, so patch it at the
533+
# source module — that's where the `from ... import` resolves.
534+
with patch("openkb.cli._find_kb_dir", return_value=tmp_path), \
535+
patch("openkb.url_ingest.fetch_url_to_raw", return_value=fetched_path), \
536+
patch("openkb.cli.convert_document",
537+
return_value=ConvertResult(skipped=True)):
538+
result = runner.invoke(cli, ["add", "https://example.com/paper.pdf"])
539+
540+
assert result.exit_code == 0, result.output
541+
assert "[SKIP]" in result.output
542+
# Orphan cleanup: the URL-fetched file must be gone from raw/.
543+
assert not fetched_path.exists()

0 commit comments

Comments
 (0)