Skip to content

Commit d0bb762

Browse files
committed
fix(url-ingest): preserve raw file on pipeline failure, fix HTML echo
Round-2 review on commit 47e5ec2 surfaced two issues: 1. add_single_file returned bool, so the URL branch couldn't tell "dedup skip" from "mid-pipeline failure". A transient LLM error during compile_long_doc would delete the just-downloaded PDF and leave a dangling PageIndex entry (hash registration only happens on full success, so `openkb remove` couldn't recover it). Now the function returns Literal["added","skipped","failed"]; the URL branch only unlinks on "skipped" — failures keep the raw file so the user can retry without re-downloading. 2. _extract_html echoed the pre-collision filename instead of target.name, so on a title collision the user was told the file was saved to a path occupied by the previous document. PDF branch already used target.name correctly; HTML branch now matches.
1 parent 47e5ec2 commit d0bb762

3 files changed

Lines changed: 112 additions & 29 deletions

File tree

‎openkb/cli.py‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import sys
1515
import time
1616
from pathlib import Path
17+
from typing import Literal
1718

1819
import os
1920

@@ -130,7 +131,7 @@ def _find_kb_dir(override: Path | None = None) -> Path | None:
130131
return None
131132

132133

133-
def add_single_file(file_path: Path, kb_dir: Path) -> bool:
134+
def add_single_file(file_path: Path, kb_dir: Path) -> Literal["added", "skipped", "failed"]:
134135
"""Convert, index, and compile a single document into the knowledge base.
135136
136137
Steps:
@@ -140,11 +141,12 @@ def add_single_file(file_path: Path, kb_dir: Path) -> bool:
140141
4. Else: compile_short_doc.
141142
142143
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.
144+
``"added"`` on full success, ``"skipped"`` when the file's hash
145+
is already in the registry (dedup), or ``"failed"`` when any
146+
pipeline stage raised. URL-ingest distinguishes these so it can
147+
unlink the just-downloaded raw file on dedup (it would otherwise
148+
be an orphan) while preserving it on failure so the user can
149+
retry without re-downloading.
148150
"""
149151
from openkb.agent.compiler import compile_long_doc, compile_short_doc
150152
from openkb.state import HashRegistry
@@ -163,11 +165,11 @@ def add_single_file(file_path: Path, kb_dir: Path) -> bool:
163165
except Exception as exc:
164166
click.echo(f" [ERROR] Conversion failed: {exc}")
165167
logger.debug("Conversion traceback:", exc_info=True)
166-
return False
168+
return "failed"
167169

168170
if result.skipped:
169171
click.echo(f" [SKIP] Already in knowledge base: {file_path.name}")
170-
return False
172+
return "skipped"
171173

172174
doc_name = file_path.stem
173175
index_result = None # populated only on the long-doc branch
@@ -181,7 +183,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> bool:
181183
except Exception as exc:
182184
click.echo(f" [ERROR] Indexing failed: {exc}")
183185
logger.debug("Indexing traceback:", exc_info=True)
184-
return False
186+
return "failed"
185187

186188
summary_path = kb_dir / "wiki" / "summaries" / f"{doc_name}.md"
187189
click.echo(f" Compiling long doc (doc_id={index_result.doc_id})...")
@@ -199,7 +201,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> bool:
199201
else:
200202
click.echo(f" [ERROR] Compilation failed: {exc}")
201203
logger.debug("Compilation traceback:", exc_info=True)
202-
return False
204+
return "failed"
203205
else:
204206
click.echo(f" Compiling short doc...")
205207
for attempt in range(2):
@@ -213,7 +215,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> bool:
213215
else:
214216
click.echo(f" [ERROR] Compilation failed: {exc}")
215217
logger.debug("Compilation traceback:", exc_info=True)
216-
return False
218+
return "failed"
217219

218220
# Register hash only after successful compilation
219221
if result.file_hash:
@@ -232,7 +234,7 @@ def add_single_file(file_path: Path, kb_dir: Path) -> bool:
232234

233235
append_log(kb_dir / "wiki", "ingest", file_path.name)
234236
click.echo(f" [OK] {file_path.name} added to knowledge base.")
235-
return True
237+
return "added"
236238

237239

238240
# ---------------------------------------------------------------------------
@@ -426,10 +428,12 @@ def add(ctx, path):
426428
fetched = fetch_url_to_raw(path, kb_dir)
427429
if fetched is None:
428430
return
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.
431+
outcome = add_single_file(fetched, kb_dir)
432+
# Only clean up on dedup-skip. On "failed" we keep the file so
433+
# the user can retry (e.g. transient LLM error during compile)
434+
# without re-downloading — and so they don't lose data when
435+
# indexing has already succeeded but compilation didn't.
436+
if outcome == "skipped":
433437
fetched.unlink(missing_ok=True)
434438
return
435439

‎openkb/url_ingest.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ def _extract_html(url: str, raw_dir: Path) -> Path | None:
208208
target.write_text(markdown, encoding="utf-8")
209209
click.echo(
210210
f" Extracted: {title!r}\n"
211-
f" Saved: raw/{filename} ({len(markdown) // 1024 or 1} KB clean markdown)"
211+
f" Saved: raw/{target.name} ({len(markdown) // 1024 or 1} KB clean markdown)"
212212
)
213213
return target
214214

‎tests/test_url_ingest.py‎

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,10 @@ def test_fetch_pdf_picks_unique_name_when_target_exists(tmp_path):
414414
assert result.read_bytes() == body
415415

416416

417-
def test_fetch_html_picks_unique_name_when_target_exists(tmp_path):
418-
"""Two blog posts both titled 'Introduction' must NOT collide."""
417+
def test_fetch_html_picks_unique_name_when_target_exists(tmp_path, capsys):
418+
"""Two blog posts both titled 'Introduction' must NOT collide. The
419+
user-facing 'Saved: ...' echo must also reflect the renamed path —
420+
otherwise the message lies about where the file actually went."""
419421
raw_dir = tmp_path / "raw"
420422
raw_dir.mkdir()
421423
(raw_dir / "Introduction.md").write_text("first blog post body")
@@ -435,6 +437,8 @@ def test_fetch_html_picks_unique_name_when_target_exists(tmp_path):
435437
assert (raw_dir / "Introduction.md").read_text() == "first blog post body"
436438
assert result == raw_dir / "Introduction_2.md"
437439
assert result.read_text() == second_md
440+
out = capsys.readouterr().out
441+
assert "Saved: raw/Introduction_2.md" in out
438442

439443

440444
def test_fetch_pdf_uses_post_redirect_url_for_filename(tmp_path):
@@ -458,10 +462,10 @@ def test_fetch_pdf_uses_post_redirect_url_for_filename(tmp_path):
458462
assert result.name == "great-paper.pdf"
459463

460464

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+
def test_add_single_file_returns_added_on_success(tmp_path):
466+
"""Tri-state return contract: ``"added"`` when the file was newly
467+
indexed. URL-ingest uses this to decide whether to keep / unlink
468+
the just-downloaded file."""
465469
from openkb.cli import add_single_file
466470
from openkb.converter import ConvertResult
467471

@@ -487,12 +491,12 @@ def test_add_single_file_returns_true_on_success(tmp_path):
487491

488492
with patch("openkb.cli.convert_document", return_value=mock_result), \
489493
patch("openkb.cli.asyncio.run"):
490-
added = add_single_file(doc, tmp_path)
494+
outcome = add_single_file(doc, tmp_path)
491495

492-
assert added is True
496+
assert outcome == "added"
493497

494498

495-
def test_add_single_file_returns_false_on_skip(tmp_path):
499+
def test_add_single_file_returns_skipped_on_dedup(tmp_path):
496500
from openkb.cli import add_single_file
497501
from openkb.converter import ConvertResult
498502

@@ -505,14 +509,48 @@ def test_add_single_file_returns_false_on_skip(tmp_path):
505509

506510
skipped = ConvertResult(skipped=True)
507511
with patch("openkb.cli.convert_document", return_value=skipped):
508-
added = add_single_file(doc, tmp_path)
512+
outcome = add_single_file(doc, tmp_path)
509513

510-
assert added is False
514+
assert outcome == "skipped"
515+
516+
517+
def test_add_single_file_returns_failed_on_pipeline_error(tmp_path):
518+
"""A pipeline failure (e.g. transient LLM error during compilation)
519+
must be distinguishable from dedup-skip, so URL-ingest can preserve
520+
the raw file for retry instead of deleting it."""
521+
from openkb.cli import add_single_file
522+
from openkb.converter import ConvertResult
523+
524+
(tmp_path / ".openkb").mkdir()
525+
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n")
526+
(tmp_path / ".openkb" / "hashes.json").write_text("{}")
527+
(tmp_path / "raw").mkdir()
528+
(tmp_path / "wiki" / "summaries").mkdir(parents=True)
529+
(tmp_path / "wiki" / "sources").mkdir(parents=True)
530+
(tmp_path / "wiki" / "log.md").write_text("")
531+
532+
doc = tmp_path / "raw" / "x.md"
533+
doc.write_text("# Hello")
534+
source_path = tmp_path / "wiki" / "sources" / "x.md"
535+
source_path.write_text("# Hello")
536+
537+
mock_result = ConvertResult(
538+
raw_path=doc, source_path=source_path,
539+
is_long_doc=False, file_hash="cafe" * 16,
540+
)
541+
542+
# Make both compile attempts raise to drive the failure path.
543+
with patch("openkb.cli.convert_document", return_value=mock_result), \
544+
patch("openkb.cli.asyncio.run", side_effect=RuntimeError("LLM 503")), \
545+
patch("openkb.cli.time.sleep"):
546+
outcome = add_single_file(doc, tmp_path)
547+
548+
assert outcome == "failed"
511549

512550

513551
def test_url_ingest_cleans_up_orphan_on_dedup_skip(tmp_path, monkeypatch):
514552
"""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/
553+
add_single_file returns "skipped" and the CLI unlinks it from raw/
516554
so the user doesn't accumulate untracked duplicates."""
517555
from click.testing import CliRunner
518556
from openkb.cli import cli
@@ -541,3 +579,44 @@ def test_url_ingest_cleans_up_orphan_on_dedup_skip(tmp_path, monkeypatch):
541579
assert "[SKIP]" in result.output
542580
# Orphan cleanup: the URL-fetched file must be gone from raw/.
543581
assert not fetched_path.exists()
582+
583+
584+
def test_url_ingest_keeps_raw_file_on_pipeline_failure(tmp_path):
585+
"""The point of the tri-state return: a pipeline failure (e.g. LLM
586+
timeout during compilation) must NOT delete the downloaded file —
587+
the user can retry without re-downloading, and we don't lose data
588+
when indexing has already succeeded but compilation hasn't."""
589+
from click.testing import CliRunner
590+
from openkb.cli import cli
591+
from openkb.converter import ConvertResult
592+
593+
(tmp_path / ".openkb").mkdir()
594+
(tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n")
595+
(tmp_path / ".openkb" / "hashes.json").write_text("{}")
596+
(tmp_path / "raw").mkdir()
597+
(tmp_path / "wiki" / "summaries").mkdir(parents=True)
598+
(tmp_path / "wiki" / "sources").mkdir(parents=True)
599+
(tmp_path / "wiki" / "log.md").write_text("")
600+
601+
fetched_path = tmp_path / "raw" / "paper.pdf"
602+
fetched_path.write_bytes(b"%PDF-fake")
603+
source_path = tmp_path / "wiki" / "sources" / "paper.md"
604+
source_path.write_text("# fake")
605+
606+
mock_result = ConvertResult(
607+
raw_path=fetched_path, source_path=source_path,
608+
is_long_doc=False, file_hash="cafe" * 16,
609+
)
610+
611+
runner = CliRunner()
612+
with patch("openkb.cli._find_kb_dir", return_value=tmp_path), \
613+
patch("openkb.url_ingest.fetch_url_to_raw", return_value=fetched_path), \
614+
patch("openkb.cli.convert_document", return_value=mock_result), \
615+
patch("openkb.cli.asyncio.run", side_effect=RuntimeError("LLM 503")), \
616+
patch("openkb.cli.time.sleep"):
617+
result = runner.invoke(cli, ["add", "https://example.com/paper.pdf"])
618+
619+
assert result.exit_code == 0, result.output
620+
assert "[ERROR] Compilation failed" in result.output
621+
# The raw file must be preserved so the user can retry.
622+
assert fetched_path.exists()

0 commit comments

Comments
 (0)