pdf2wiki 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/CHANGELOG.md +26 -0
  2. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/PKG-INFO +1 -1
  3. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/pyproject.toml +1 -1
  4. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/__init__.py +1 -1
  5. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/cli.py +23 -2
  6. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/convert/merge.py +6 -1
  7. pdf2wiki-0.2.4/src/pdf2wiki/qa/flagged.py +42 -0
  8. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/tests/test_batch.py +14 -0
  9. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/tests/test_convert.py +33 -0
  10. pdf2wiki-0.2.4/tests/test_qa.py +94 -0
  11. pdf2wiki-0.2.2/tests/test_qa.py +0 -44
  12. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/.claude-plugin/marketplace.json +0 -0
  13. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/.gitignore +0 -0
  14. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/CONTRIBUTING.md +0 -0
  15. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/LICENSE +0 -0
  16. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/README.md +0 -0
  17. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/README.md +0 -0
  18. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/architecture/architecture.md +0 -0
  19. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/explanation/design-principles.md +0 -0
  20. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/explanation/how-the-merge-works.md +0 -0
  21. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/explanation/why-dual-backend.md +0 -0
  22. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/convert-a-book.md +0 -0
  23. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/convert-in-the-cloud.md +0 -0
  24. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/install.md +0 -0
  25. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/offload-hybrid-to-a-server.md +0 -0
  26. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/post-process-and-split.md +0 -0
  27. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/qa-a-conversion.md +0 -0
  28. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/run-a-batch.md +0 -0
  29. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/set-up-remote-gpu.md +0 -0
  30. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/how-to/troubleshoot.md +0 -0
  31. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/README.md +0 -0
  32. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/explanation/why-llm-wiki.md +0 -0
  33. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/how-to/configure-vault-location.md +0 -0
  34. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/how-to/install.md +0 -0
  35. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/how-to/review-a-project.md +0 -0
  36. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/reference/skills-and-agent.md +0 -0
  37. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/reference/vault-layout.md +0 -0
  38. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/llm-wiki/tutorials/query-your-first-vault.md +0 -0
  39. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/reference/cli.md +0 -0
  40. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/reference/configuration.md +0 -0
  41. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/reference/output-layout.md +0 -0
  42. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/reference/phase5-steps.md +0 -0
  43. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/reference/pipeline-stages.md +0 -0
  44. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/docs/tutorials/convert-your-first-book.md +0 -0
  45. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/plugin/.claude-plugin/plugin.json +0 -0
  46. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/plugin/LICENSE +0 -0
  47. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/plugin/README.md +0 -0
  48. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/plugin/agents/knowledge-researcher.md +0 -0
  49. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/plugin/skills/knowledge-query/SKILL.md +0 -0
  50. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/plugin/skills/knowledge-review/SKILL.md +0 -0
  51. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/batch.py +0 -0
  52. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/config.py +0 -0
  53. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/convert/__init__.py +0 -0
  54. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/convert/cloud.py +0 -0
  55. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/executor.py +0 -0
  56. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/phase5/__init__.py +0 -0
  57. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/phase5/caption_unbleed.py +0 -0
  58. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/phase5/chapter_split.py +0 -0
  59. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/phase5/code_unescape.py +0 -0
  60. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/phase5/dash_normalize.py +0 -0
  61. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/phase5/lang_retag.py +0 -0
  62. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/phase5/mermaid_repair.py +0 -0
  63. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/qa/__init__.py +0 -0
  64. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/qa/review.py +0 -0
  65. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/qa/sample.py +0 -0
  66. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/render.py +0 -0
  67. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/src/pdf2wiki/scan.py +0 -0
  68. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/tests/test_cloud.py +0 -0
  69. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/tests/test_executor.py +0 -0
  70. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/tests/test_phase5.py +0 -0
  71. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/tests/test_render.py +0 -0
  72. {pdf2wiki-0.2.2 → pdf2wiki-0.2.4}/tests/test_scan.py +0 -0
@@ -4,6 +4,32 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.4] - 2026-07-23
8
+
9
+ ### Fixed
10
+ - **Remote convert produced no output** (`no content_list.json`). `run_mineru` handed MinerU a
11
+ *relative* `-o`/`-p`, but MinerU runs with a different working directory (the stdlib-shadow-safe
12
+ `clean_cwd`), so its output landed where pdf2wiki couldn't find it. Only surfaced in `--remote` mode,
13
+ where `--out` is passed home-relative. Now the paths handed to MinerU are absolutized
14
+ (`os.path.abspath`) — idempotent for the already-absolute local case. Found by the first real
15
+ end-to-end remote run; +1 regression test reproducing the cwd divergence.
16
+
17
+ ## [0.2.3] - 2026-07-23
18
+
19
+ QA + diagnostics from an external review of the repo. No breaking changes; +4 tests (103 → 107).
20
+
21
+ ### Added
22
+ - **`pdf2wiki qa flags PATHS...`** — per-book report of the code blocks where the VLM diverged from the
23
+ byte-clean text layer (`_code_flag`), or where hybrid indentation failed a Python ast check
24
+ (`_indent_flag`), read from `blocks.json`. Ranks multiple books by flagged count (which books to
25
+ trust least) and lists each flagged block (page / language / snippet) for a single book — the
26
+ highest-signal spots to spot-check.
27
+
28
+ ### Changed
29
+ - **Batch summary rolls up `error_class`** — a partial batch now prints, e.g.,
30
+ `5 book(s) not done — by class: permanent×3, timeout×1, fetch×1` (plus the slug list), so a cluster
31
+ of same-kind failures reads as one diagnosis instead of N separate slugs. Exit code unchanged.
32
+
7
33
  ## [0.2.2] - 2026-07-23
8
34
 
9
35
  Resilience + security hardening from a book-grounded review (Tech-Books vault: Backoff-Retries,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdf2wiki
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Convert technical books (native-text PDFs) into clean, chapter-split, LLM-ready Markdown using a dual-pass MinerU pipeline plus fidelity-preserving post-processing.
5
5
  Project-URL: Homepage, https://github.com/Sevthered/pdf2wiki
6
6
  Project-URL: Repository, https://github.com/Sevthered/pdf2wiki
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pdf2wiki"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Convert technical books (native-text PDFs) into clean, chapter-split, LLM-ready Markdown using a dual-pass MinerU pipeline plus fidelity-preserving post-processing."
5
5
  authors = [{ name = "Sevthered" }]
6
6
  readme = "README.md"
@@ -11,4 +11,4 @@ Pipeline stages:
11
11
  4. batch — manifest-driven multi-book runs, resumable, with optional SSH remote execution.
12
12
  """
13
13
 
14
- __version__ = "0.2.2"
14
+ __version__ = "0.2.4"
@@ -99,6 +99,19 @@ def _cmd_qa_review(a, cfg):
99
99
  return 0
100
100
 
101
101
 
102
+ def _cmd_qa_flagged(a, cfg):
103
+ from .qa.flagged import flagged_report
104
+ reports = sorted((flagged_report(p) for p in a.blocks), key=lambda r: r["flagged"], reverse=True)
105
+ print(f"{'flagged':>7} {'diverged':>8} {'indent':>6} {'code':>5} book")
106
+ for r in reports:
107
+ print(f"{r['flagged']:>7} {r['diverged']:>8} {r['indent_suspect']:>6} {r['code_blocks']:>5} {r['name']}")
108
+ if len(reports) == 1: # single book -> also list the blocks to eyeball
109
+ print()
110
+ for e in reports[0]["blocks"]:
111
+ print(f" p{e['page']:<4} [{e['lang'] or '?'}] {e['flag']}: {e['snippet']}")
112
+ return 0
113
+
114
+
102
115
  def _cmd_scan(a, cfg):
103
116
  from .scan import scan_dir
104
117
  print(json.dumps(scan_dir(a.directory), indent=1))
@@ -109,9 +122,13 @@ def _cmd_batch(a, cfg):
109
122
  from .batch import run_batch
110
123
  manifest = run_batch(a.books, cfg, a.stage, remote=a.remote or (cfg.remote.host or None),
111
124
  max_books=a.max_books, only=a.only, vault=a.vault or (cfg.output.vault or None))
112
- failed = [s for s, e in manifest.items() if e.get("status") != "done"]
125
+ failed = [(s, e) for s, e in manifest.items() if e.get("status") != "done"]
113
126
  if failed: # non-zero exit so CI/automation can detect a partial batch
114
- print(f"batch: {len(failed)} book(s) not done: {failed}", file=sys.stderr)
127
+ from collections import Counter
128
+ by_class = Counter(e.get("error_class", "unknown") for _, e in failed)
129
+ rollup = ", ".join(f"{k}×{v}" for k, v in by_class.most_common())
130
+ print(f"batch: {len(failed)} book(s) not done — by class: {rollup}", file=sys.stderr)
131
+ print(f" not done: {[s for s, _ in failed]}", file=sys.stderr)
115
132
  return 1
116
133
  return 0
117
134
 
@@ -166,6 +183,10 @@ def main(argv=None):
166
183
  p.add_argument("name")
167
184
  p.add_argument("--blocks", default=None, help="explicit blocks.json path")
168
185
  p.set_defaults(fn=_cmd_qa_review)
186
+ p = qsub.add_parser("flags", help="report code blocks the VLM diverged on (from blocks.json), "
187
+ "ranked across books — the highest-signal QA sample")
188
+ p.add_argument("blocks", nargs="+", help="one or more converted-book blocks.json paths")
189
+ p.set_defaults(fn=_cmd_qa_flagged)
169
190
 
170
191
  p = sub.add_parser("scan", help="scan a directory of PDFs -> title/year guesses (JSON)")
171
192
  p.add_argument("directory")
@@ -179,7 +179,12 @@ def run_mineru(mineru_bin, pdf, a, b, backend, extra, outdir, clean_cwd, env,
179
179
  cl = glob.glob(f"{outdir}/*/*/*content_list.json")
180
180
  if not (os.path.exists(done) and cl):
181
181
  os.makedirs(outdir, exist_ok=True)
182
- cmd = [mineru_bin, "-p", pdf, "-o", outdir, "-b", backend, "-s", str(a), "-e", str(b)] + extra
182
+ # MinerU runs with cwd=clean_cwd (stdlib-shadow-safe), NOT pdf2wiki's cwd so any relative path
183
+ # handed to it resolves against the wrong directory and its output lands where our glob can't see
184
+ # it (remote mode passes a home-relative --out). Absolutize the paths MinerU receives; abspath is
185
+ # idempotent for already-absolute (local) paths. See bug-pdf2wiki-remote-relative-outdir.
186
+ cmd = ([mineru_bin, "-p", os.path.abspath(pdf), "-o", os.path.abspath(outdir),
187
+ "-b", backend, "-s", str(a), "-e", str(b)] + extra)
183
188
  print(" run:", " ".join(cmd[3:]))
184
189
  with open(f"{outdir}.log", "w", encoding="utf-8") as log:
185
190
  # start_new_session=True puts MinerU in its own process group so a timeout can SIGKILL the
@@ -0,0 +1,42 @@
1
+ """Per-book flagged-block report: which code blocks the VLM diverged on — the highest-signal QA
2
+ sample. Pure read over a converted book's blocks.json (no converter or schema change). The flags are
3
+ set during merge in convert/merge.py: `_code_flag` = the hybrid/VLM code diverged from the byte-clean
4
+ text layer (output shows the pipeline tokens); `_indent_flag` = tokens agreed but the hybrid
5
+ indentation failed a Python ast sanity check. These are exactly the places worth eyeballing."""
6
+ import json
7
+ import os
8
+
9
+
10
+ def flagged_report(blocks_path: str, name: str | None = None) -> dict:
11
+ """Summarize the flagged code blocks in one book's blocks.json.
12
+
13
+ Returns {name, code_blocks, flagged, diverged, indent_suspect, blocks} where `blocks` is the
14
+ page-sorted list of flagged entries {page, lang, flag, snippet}; `page` is 1-based for display.
15
+ """
16
+ path = os.path.expanduser(blocks_path)
17
+ with open(path, encoding="utf-8") as f:
18
+ blocks = json.load(f)
19
+ name = name or os.path.basename(os.path.dirname(os.path.abspath(path)))
20
+
21
+ code_blocks = 0
22
+ flagged = []
23
+ for b in blocks:
24
+ if b.get("type") != "code":
25
+ continue
26
+ code_blocks += 1
27
+ if b.get("_code_flag"):
28
+ flag = "diverged"
29
+ elif b.get("_indent_flag"):
30
+ flag = "indent"
31
+ else:
32
+ continue
33
+ body = str(b.get("code_body") or "")
34
+ snippet = next((ln.strip() for ln in body.splitlines() if ln.strip()), "")[:80]
35
+ flagged.append({"page": int(b.get("abs_page", 0)) + 1, "lang": b.get("sub_type") or "",
36
+ "flag": flag, "snippet": snippet})
37
+
38
+ flagged.sort(key=lambda e: e["page"])
39
+ return {"name": name, "code_blocks": code_blocks, "flagged": len(flagged),
40
+ "diverged": sum(1 for e in flagged if e["flag"] == "diverged"),
41
+ "indent_suspect": sum(1 for e in flagged if e["flag"] == "indent"),
42
+ "blocks": flagged}
@@ -125,3 +125,17 @@ def test_no_breaker_when_executor_stays_healthy(tmp_path, monkeypatch):
125
125
  failed = [s for s, e in manifest.items() if e["status"] == "convert_failed"]
126
126
  assert len(failed) == 5 # all attempted, no premature abort
127
127
  assert manifest["book-0"]["error_class"] == "permanent"
128
+
129
+
130
+ def test_cmd_batch_rolls_up_error_class(monkeypatch, capsys):
131
+ # a partial batch must aggregate error_class so a cluster of same-kind failures reads as one line.
132
+ monkeypatch.setattr(batch, "run_batch",
133
+ lambda *a, **k: {"book-a": {"status": "done"},
134
+ "book-b": {"status": "convert_failed", "error_class": "permanent"},
135
+ "book-c": {"status": "convert_failed", "error_class": "permanent"},
136
+ "book-d": {"status": "convert_failed", "error_class": "TimeoutExpired"}})
137
+ assert cli.main(["batch", "books.toml"]) == 1
138
+ err = capsys.readouterr().err
139
+ assert "3 book(s) not done" in err
140
+ assert "permanent×2" in err and "TimeoutExpired×1" in err
141
+ assert "not done:" in err # slug detail retained
@@ -1,4 +1,5 @@
1
1
  """Unit tests for the converter's pure functions (no MinerU / GPU needed)."""
2
+ import json
2
3
  import os
3
4
  import subprocess
4
5
  import sys
@@ -346,3 +347,35 @@ def test_render_emits_code_verify_flag():
346
347
  out = render(flagged)
347
348
  assert out.startswith("<!-- code-verify:")
348
349
  assert "private_key" in out
350
+
351
+
352
+ def test_run_mineru_passes_absolute_paths_to_mineru(tmp_path, monkeypatch):
353
+ # MinerU runs in clean_cwd, NOT pdf2wiki's cwd. If run_mineru hands it a relative -o/-p, MinerU's
354
+ # output lands under clean_cwd and pdf2wiki's glob (against its own cwd) misses it -> the remote-mode
355
+ # "no content_list.json" bug. run_mineru must absolutize the paths it hands MinerU.
356
+ proj = tmp_path / "proj"; proj.mkdir()
357
+ clean = tmp_path / "clean"; clean.mkdir() # a DIFFERENT cwd, as clean_cwd would be on the box
358
+ monkeypatch.chdir(proj) # pdf2wiki's cwd
359
+ (proj / "in.pdf").write_bytes(b"%PDF-1.4")
360
+ captured = {}
361
+
362
+ class FakeProc:
363
+ pid = 4321
364
+ def __init__(self, cmd, cwd):
365
+ captured["cmd"] = cmd
366
+ odir = cmd[cmd.index("-o") + 1]
367
+ # simulate MinerU resolving its -o against ITS cwd (clean_cwd) when relative
368
+ base = odir if os.path.isabs(odir) else os.path.join(cwd, odir)
369
+ dst = os.path.join(base, "in", "txt"); os.makedirs(dst, exist_ok=True)
370
+ with open(os.path.join(dst, "in_content_list.json"), "w") as f:
371
+ json.dump([{"type": "text", "page_idx": 0, "bbox": [0, 0, 1, 1], "text": "x"}], f)
372
+ def wait(self, timeout=None): return 0
373
+
374
+ monkeypatch.setattr(merge_mod.subprocess, "Popen",
375
+ lambda cmd, **k: FakeProc(cmd, k.get("cwd")))
376
+ blocks, _ = run_mineru("mineru", "in.pdf", 0, 1, "pipeline", ["-m", "txt"],
377
+ "reldir", str(clean), {}, label="pipeline 0-1", timeout=60)
378
+ oi, pi = captured["cmd"].index("-o"), captured["cmd"].index("-p")
379
+ assert os.path.isabs(captured["cmd"][oi + 1]), "MinerU -o must be absolute"
380
+ assert os.path.isabs(captured["cmd"][pi + 1]), "MinerU -p must be absolute"
381
+ assert blocks and blocks[0]["text"] == "x" # found the content_list MinerU 'wrote'
@@ -0,0 +1,94 @@
1
+ """Tests for qa.sample page-window math on short books (no GPU/MinerU needed).
2
+
3
+ Regression: `random.sample(range(lo, hi), n)` raised `ValueError: Sample larger than
4
+ population` on any book whose 5..95% window was smaller than the requested sample size.
5
+ """
6
+ import os
7
+ import sys
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
+
11
+ import pymupdf # runtime dependency
12
+
13
+ from pdf2wiki.qa.sample import sample_pages
14
+
15
+
16
+ def _make_pdf(path, pages):
17
+ d = pymupdf.open()
18
+ for _ in range(pages):
19
+ d.new_page(width=200, height=200)
20
+ d.save(path)
21
+ d.close()
22
+
23
+
24
+ def test_sample_short_book_clamps_instead_of_crashing(tmp_path):
25
+ pdf = str(tmp_path / "short.pdf")
26
+ _make_pdf(pdf, 5) # window 0..4 < default n=20
27
+ r = sample_pages(pdf, "short", str(tmp_path / "qa"), n=20)
28
+ assert 0 < len(r["pages"]) <= 4 # clamped to the window, no ValueError
29
+ assert len(set(r["pages"])) == len(r["pages"]) # still distinct
30
+
31
+
32
+ def test_sample_single_page_book(tmp_path):
33
+ pdf = str(tmp_path / "one.pdf")
34
+ _make_pdf(pdf, 1) # 5..95% window empty -> falls back to whole book
35
+ r = sample_pages(pdf, "one", str(tmp_path / "qa"), n=20)
36
+ assert r["pages"] == [0]
37
+
38
+
39
+ def test_sample_normal_book_unchanged(tmp_path):
40
+ pdf = str(tmp_path / "big.pdf")
41
+ _make_pdf(pdf, 100) # window 5..95 holds 90 pages
42
+ r = sample_pages(pdf, "big", str(tmp_path / "qa"), n=20)
43
+ assert len(r["pages"]) == 20
44
+ assert all(5 <= p < 95 for p in r["pages"])
45
+
46
+
47
+ # ---------- qa.flagged: per-book flagged-block report (T3) ----------
48
+ import json
49
+
50
+ import pdf2wiki.cli as cli
51
+ from pdf2wiki.qa.flagged import flagged_report
52
+
53
+
54
+ def _write_blocks(dirpath, specs):
55
+ """specs: list of (type, flag) where flag in {None, '_code_flag', '_indent_flag'}."""
56
+ os.makedirs(dirpath, exist_ok=True)
57
+ blocks = []
58
+ for i, (t, flag) in enumerate(specs):
59
+ b = {"type": t, "abs_page": i, "sub_type": "python", "code_body": f"line_{i} = {i}\nmore"}
60
+ if flag:
61
+ b[flag] = True
62
+ blocks.append(b)
63
+ p = os.path.join(dirpath, "blocks.json")
64
+ with open(p, "w", encoding="utf-8") as f:
65
+ json.dump(blocks, f)
66
+ return p
67
+
68
+
69
+ def test_flagged_report_counts(tmp_path):
70
+ p = _write_blocks(str(tmp_path / "mybook"), [
71
+ ("code", None), ("code", "_code_flag"), ("code", "_indent_flag"),
72
+ ("code", "_code_flag"), ("text", None)])
73
+ r = flagged_report(p)
74
+ assert r["name"] == "mybook"
75
+ assert r["code_blocks"] == 4 # 4 code blocks, the text block ignored
76
+ assert r["diverged"] == 2 and r["indent_suspect"] == 1 and r["flagged"] == 3
77
+ assert [e["flag"] for e in r["blocks"]] == ["diverged", "indent", "diverged"] # page-sorted
78
+ assert r["blocks"][0]["page"] == 2 and r["blocks"][0]["lang"] == "python" # abs_page 1 -> pg 2
79
+ assert r["blocks"][0]["snippet"].startswith("line_1")
80
+
81
+
82
+ def test_flagged_report_empty(tmp_path):
83
+ p = _write_blocks(str(tmp_path / "clean"), [("code", None), ("code", None), ("text", None)])
84
+ r = flagged_report(p)
85
+ assert r["flagged"] == 0 and r["blocks"] == [] and r["code_blocks"] == 2
86
+
87
+
88
+ def test_cmd_qa_flags_ranks_books(tmp_path, capsys):
89
+ a = _write_blocks(str(tmp_path / "alpha"), [("code", "_code_flag"), ("code", None)]) # 1 flag
90
+ b = _write_blocks(str(tmp_path / "beta"),
91
+ [("code", "_code_flag"), ("code", "_code_flag"), ("code", "_indent_flag")]) # 3 flags
92
+ assert cli.main(["qa", "flags", a, b]) == 0
93
+ out = capsys.readouterr().out
94
+ assert out.index("beta") < out.index("alpha") # most-flagged book ranked first
@@ -1,44 +0,0 @@
1
- """Tests for qa.sample page-window math on short books (no GPU/MinerU needed).
2
-
3
- Regression: `random.sample(range(lo, hi), n)` raised `ValueError: Sample larger than
4
- population` on any book whose 5..95% window was smaller than the requested sample size.
5
- """
6
- import os
7
- import sys
8
-
9
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
-
11
- import pymupdf # runtime dependency
12
-
13
- from pdf2wiki.qa.sample import sample_pages
14
-
15
-
16
- def _make_pdf(path, pages):
17
- d = pymupdf.open()
18
- for _ in range(pages):
19
- d.new_page(width=200, height=200)
20
- d.save(path)
21
- d.close()
22
-
23
-
24
- def test_sample_short_book_clamps_instead_of_crashing(tmp_path):
25
- pdf = str(tmp_path / "short.pdf")
26
- _make_pdf(pdf, 5) # window 0..4 < default n=20
27
- r = sample_pages(pdf, "short", str(tmp_path / "qa"), n=20)
28
- assert 0 < len(r["pages"]) <= 4 # clamped to the window, no ValueError
29
- assert len(set(r["pages"])) == len(r["pages"]) # still distinct
30
-
31
-
32
- def test_sample_single_page_book(tmp_path):
33
- pdf = str(tmp_path / "one.pdf")
34
- _make_pdf(pdf, 1) # 5..95% window empty -> falls back to whole book
35
- r = sample_pages(pdf, "one", str(tmp_path / "qa"), n=20)
36
- assert r["pages"] == [0]
37
-
38
-
39
- def test_sample_normal_book_unchanged(tmp_path):
40
- pdf = str(tmp_path / "big.pdf")
41
- _make_pdf(pdf, 100) # window 5..95 holds 90 pages
42
- r = sample_pages(pdf, "big", str(tmp_path / "qa"), n=20)
43
- assert len(r["pages"]) == 20
44
- assert all(5 <= p < 95 for p in r["pages"])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes