visual-parser 2.2.2__tar.gz → 2.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 (48) hide show
  1. {visual_parser-2.2.2/visual_parser.egg-info → visual_parser-2.2.4}/PKG-INFO +12 -8
  2. {visual_parser-2.2.2 → visual_parser-2.2.4}/README.md +9 -5
  3. {visual_parser-2.2.2 → visual_parser-2.2.4}/pyproject.toml +3 -3
  4. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_figure_describer.py +149 -0
  5. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_image_describer.py +7 -5
  6. visual_parser-2.2.4/tests/test_pdf_tracker.py +102 -0
  7. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_pipeline.py +279 -10
  8. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_prompts.py +1 -1
  9. visual_parser-2.2.4/visual_parser/__init__.py +22 -0
  10. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/cli.py +5 -5
  11. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/cli_main.py +6 -6
  12. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/figure_describer.py +49 -11
  13. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/image_describer.py +14 -10
  14. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/image_tracker.py +4 -7
  15. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/kb_redo.py +8 -3
  16. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/pdf_tracker.py +14 -2
  17. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/pipeline.py +62 -6
  18. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/text_extractor.py +3 -3
  19. {visual_parser-2.2.2 → visual_parser-2.2.4/visual_parser.egg-info}/PKG-INFO +12 -8
  20. visual_parser-2.2.2/tests/test_pdf_tracker.py +0 -48
  21. visual_parser-2.2.2/visual_parser/__init__.py +0 -20
  22. {visual_parser-2.2.2 → visual_parser-2.2.4}/LICENSE +0 -0
  23. {visual_parser-2.2.2 → visual_parser-2.2.4}/setup.cfg +0 -0
  24. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_cli_parity.py +0 -0
  25. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_config.py +0 -0
  26. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_image_tracker.py +0 -0
  27. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_integration.py +0 -0
  28. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_jsonl_writer.py +0 -0
  29. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_kb_redo.py +0 -0
  30. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_metadata_extractor.py +0 -0
  31. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_model_catalog.py +0 -0
  32. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_ollama_local.py +0 -0
  33. {visual_parser-2.2.2 → visual_parser-2.2.4}/tests/test_vision_llm.py +0 -0
  34. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/__main__.py +0 -0
  35. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/config.py +0 -0
  36. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/jsonl_writer.py +0 -0
  37. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/metadata_extractor.py +0 -0
  38. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/model_catalog.py +0 -0
  39. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/nougat_engine.py +0 -0
  40. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/ollama_local.py +0 -0
  41. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/openai_gateway.py +0 -0
  42. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/prompts.py +0 -0
  43. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser/vision_llm.py +0 -0
  44. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser.egg-info/SOURCES.txt +0 -0
  45. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser.egg-info/dependency_links.txt +0 -0
  46. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser.egg-info/entry_points.txt +0 -0
  47. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser.egg-info/requires.txt +0 -0
  48. {visual_parser-2.2.2 → visual_parser-2.2.4}/visual_parser.egg-info/top_level.txt +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visual-parser
3
- Version: 2.2.2
4
- Summary: Standalone Visual-RAG PDF Parser - text extraction and Vision-LLM figure descriptions to JSONL
3
+ Version: 2.2.4
4
+ Summary: Standalone Visual-RAG PDF & Image Parser - text extraction and Vision-LLM figure/image descriptions to JSONL
5
5
  Author: Zavier N. Ndum
6
6
  Author-email: zavier.ndum@tamu.edu
7
7
  License-Expression: Apache-2.0
8
8
  Project-URL: Homepage, https://github.com/SmartLabNuclear/RADIANT_LLM
9
9
  Project-URL: Repository, https://github.com/SmartLabNuclear/RADIANT_LLM
10
10
  Project-URL: Docker Hub, https://hub.docker.com/r/zev94/radiant-llm
11
- Keywords: pdf,rag,nougat,vision-llm,ocr,document-parsing,jsonl,knowledge-base
11
+ Keywords: pdf,image,rag,nougat,vision-llm,ocr,document-parsing,jsonl,knowledge-base
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
@@ -43,17 +43,17 @@ Requires-Dist: ruff; extra == "dev"
43
43
  Requires-Dist: mypy; extra == "dev"
44
44
  Dynamic: license-file
45
45
 
46
- # visual-parser (Standalone Visual-RAG PDF Ingestion)
46
+ # visual-parser (Standalone Visual-RAG PDF & Image Ingestion)
47
47
 
48
48
  ![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-brightgreen.svg)
49
49
  ![PyTorch](https://img.shields.io/badge/PyTorch-2.1%2B-ee4c2c.svg)
50
50
  ![LangChain](https://img.shields.io/badge/LangChain-0.1%2B-1C3C3C.svg)
51
51
  ![CUDA](https://img.shields.io/badge/CUDA-optional-76B900.svg)
52
52
 
53
- `visual-parser` converts PDFs (and standalone images alongside them) into a multi-modal JSONL knowledge base (text chunks, figure descriptions, standalone image descriptions, metadata). It was extracted from [RADIANT-LLM](https://github.com/SmartLabNuclear/RADIANT_LLM) as a standalone PDF-ingestion tool, independent of any chatbot.
53
+ `visual-parser` converts PDFs (and standalone images alongside them) into a multi-modal JSONL knowledge base (text chunks, figure descriptions, standalone image descriptions, metadata). It was extracted from [RADIANT-LLM](https://github.com/SmartLabNuclear/RADIANT_LLM) as a standalone PDF-and-image ingestion tool, independent of any chatbot.
54
54
 
55
- 1) Run `visual-parser` on curated PDFs to generate JSONL KB files.
56
- 2) Point any downstream RAG system at the generated KB — RADIANT-LLM, AutoSAM, and AutoFLUKA all consume the identical JSONL/registry format, so the same KB works with any of them without re-parsing.
55
+ 1) Run `visual-parser` on a directory of curated PDFs and/or standalone images to generate JSONL KB files.
56
+ 2) Point any downstream RAG system at the generated KB — [RADIANT-LLM](https://github.com/SmartLabNuclear/RADIANT_LLM), [AutoSAM](https://github.com/SmartLabNuclear/AutoSAM), and [AutoFLUKA](https://github.com/SmartLabNuclear/AutoFLUKA) all consume the identical JSONL/registry format, so the same KB works with any of them without re-parsing.
57
57
 
58
58
  ## Table of Contents
59
59
 
@@ -74,10 +74,14 @@ By default, the pipeline writes:
74
74
  - `01_chunks_kb.jsonl`: chunked text extracted from PDFs (Nougat by default).
75
75
  - `02_visuals_kb.jsonl`: figure/page visual descriptions (Vision LLM), one row per figure found in a PDF.
76
76
  - `03_metadata_kb.jsonl`: document metadata rows (title/author/etc.).
77
- - `image_descriptions.jsonl`: one holistic description per **standalone image** file found alongside your PDFs in `--input-dir` (not a PDF's own figures) — auto-detected by extension, no separate flag needed; disable with `--skip-images`.
77
+ - `image_descriptions_kb.jsonl`: one holistic description per **standalone image** file found alongside your PDFs in `--input-dir` (not a PDF's own figures) — auto-detected by extension, no separate flag needed; disable with `--skip-images`.
78
78
 
79
79
  Alongside the KB, the pipeline also writes bookkeeping files: `04_processed_pdfs.txt` / `parsed_images.txt` (track which PDFs/images have already been processed, so re-runs skip them unless `--rebuild` or `--redo NAME`) and `05_pipeline.log` (run log at the verbosity set by `--log-level`, default `ERROR`).
80
80
 
81
+ > **New in 2.2.4 (additive, no existing field or ID changed):** every `02_visuals_kb.jsonl` and `image_descriptions_kb.jsonl` row now carries `vision_provider`/`vision_model`, and every `03_metadata_kb.jsonl` row carries a `parser` block (`visual_parser` version, `parsed_at`, `text_engine`, `text_fallback`, the resolved `vision_model`, and the run's chunking/context settings) — recording which engine and model actually produced that document's rows, since the configured settings aren't always what ran (Nougat can fail and fall back to lightweight; a local Ollama model can be auto-selected at run time). Older rows simply lack these fields. Figure description also now actively guards against a mixed-model document: if a PDF already has figures described under a different `vision_model` than the current run's, its remaining pages are skipped entirely (not partially re-described under the new model) and its basename is reported back — reprocess it with `--redo NAME` to redescribe it consistently under the new model.
82
+
83
+ > **Breaking change in 2.2.3:** the standalone-image KB file was renamed from `image_descriptions.jsonl` to `image_descriptions_kb.jsonl` (matching the `NN_name_kb.jsonl` convention used by the other three KB files). Upgrading from an older version does **not** migrate existing data automatically — the resume check only looks at the new filename, so already-described images in an old `image_descriptions.jsonl` would otherwise appear "new" again and get re-described. If you have existing data, rename the file yourself before re-running: `image_descriptions.jsonl` → `image_descriptions_kb.jsonl`.
84
+
81
85
  ## API Keys (.env)
82
86
 
83
87
  Provide at least one provider:
@@ -1,14 +1,14 @@
1
- # visual-parser (Standalone Visual-RAG PDF Ingestion)
1
+ # visual-parser (Standalone Visual-RAG PDF & Image Ingestion)
2
2
 
3
3
  ![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-brightgreen.svg)
4
4
  ![PyTorch](https://img.shields.io/badge/PyTorch-2.1%2B-ee4c2c.svg)
5
5
  ![LangChain](https://img.shields.io/badge/LangChain-0.1%2B-1C3C3C.svg)
6
6
  ![CUDA](https://img.shields.io/badge/CUDA-optional-76B900.svg)
7
7
 
8
- `visual-parser` converts PDFs (and standalone images alongside them) into a multi-modal JSONL knowledge base (text chunks, figure descriptions, standalone image descriptions, metadata). It was extracted from [RADIANT-LLM](https://github.com/SmartLabNuclear/RADIANT_LLM) as a standalone PDF-ingestion tool, independent of any chatbot.
8
+ `visual-parser` converts PDFs (and standalone images alongside them) into a multi-modal JSONL knowledge base (text chunks, figure descriptions, standalone image descriptions, metadata). It was extracted from [RADIANT-LLM](https://github.com/SmartLabNuclear/RADIANT_LLM) as a standalone PDF-and-image ingestion tool, independent of any chatbot.
9
9
 
10
- 1) Run `visual-parser` on curated PDFs to generate JSONL KB files.
11
- 2) Point any downstream RAG system at the generated KB — RADIANT-LLM, AutoSAM, and AutoFLUKA all consume the identical JSONL/registry format, so the same KB works with any of them without re-parsing.
10
+ 1) Run `visual-parser` on a directory of curated PDFs and/or standalone images to generate JSONL KB files.
11
+ 2) Point any downstream RAG system at the generated KB — [RADIANT-LLM](https://github.com/SmartLabNuclear/RADIANT_LLM), [AutoSAM](https://github.com/SmartLabNuclear/AutoSAM), and [AutoFLUKA](https://github.com/SmartLabNuclear/AutoFLUKA) all consume the identical JSONL/registry format, so the same KB works with any of them without re-parsing.
12
12
 
13
13
  ## Table of Contents
14
14
 
@@ -29,10 +29,14 @@ By default, the pipeline writes:
29
29
  - `01_chunks_kb.jsonl`: chunked text extracted from PDFs (Nougat by default).
30
30
  - `02_visuals_kb.jsonl`: figure/page visual descriptions (Vision LLM), one row per figure found in a PDF.
31
31
  - `03_metadata_kb.jsonl`: document metadata rows (title/author/etc.).
32
- - `image_descriptions.jsonl`: one holistic description per **standalone image** file found alongside your PDFs in `--input-dir` (not a PDF's own figures) — auto-detected by extension, no separate flag needed; disable with `--skip-images`.
32
+ - `image_descriptions_kb.jsonl`: one holistic description per **standalone image** file found alongside your PDFs in `--input-dir` (not a PDF's own figures) — auto-detected by extension, no separate flag needed; disable with `--skip-images`.
33
33
 
34
34
  Alongside the KB, the pipeline also writes bookkeeping files: `04_processed_pdfs.txt` / `parsed_images.txt` (track which PDFs/images have already been processed, so re-runs skip them unless `--rebuild` or `--redo NAME`) and `05_pipeline.log` (run log at the verbosity set by `--log-level`, default `ERROR`).
35
35
 
36
+ > **New in 2.2.4 (additive, no existing field or ID changed):** every `02_visuals_kb.jsonl` and `image_descriptions_kb.jsonl` row now carries `vision_provider`/`vision_model`, and every `03_metadata_kb.jsonl` row carries a `parser` block (`visual_parser` version, `parsed_at`, `text_engine`, `text_fallback`, the resolved `vision_model`, and the run's chunking/context settings) — recording which engine and model actually produced that document's rows, since the configured settings aren't always what ran (Nougat can fail and fall back to lightweight; a local Ollama model can be auto-selected at run time). Older rows simply lack these fields. Figure description also now actively guards against a mixed-model document: if a PDF already has figures described under a different `vision_model` than the current run's, its remaining pages are skipped entirely (not partially re-described under the new model) and its basename is reported back — reprocess it with `--redo NAME` to redescribe it consistently under the new model.
37
+
38
+ > **Breaking change in 2.2.3:** the standalone-image KB file was renamed from `image_descriptions.jsonl` to `image_descriptions_kb.jsonl` (matching the `NN_name_kb.jsonl` convention used by the other three KB files). Upgrading from an older version does **not** migrate existing data automatically — the resume check only looks at the new filename, so already-described images in an old `image_descriptions.jsonl` would otherwise appear "new" again and get re-described. If you have existing data, rename the file yourself before re-running: `image_descriptions.jsonl` → `image_descriptions_kb.jsonl`.
39
+
36
40
  ## API Keys (.env)
37
41
 
38
42
  Provide at least one provider:
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "visual-parser"
7
- version = "2.2.2"
8
- description = "Standalone Visual-RAG PDF Parser - text extraction and Vision-LLM figure descriptions to JSONL"
7
+ version = "2.2.4"
8
+ description = "Standalone Visual-RAG PDF & Image Parser - text extraction and Vision-LLM figure/image descriptions to JSONL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "Apache-2.0"
@@ -17,7 +17,7 @@ authors = [
17
17
  ]
18
18
 
19
19
  keywords = [
20
- "pdf", "rag", "nougat", "vision-llm", "ocr",
20
+ "pdf", "image", "rag", "nougat", "vision-llm", "ocr",
21
21
  "document-parsing", "jsonl", "knowledge-base",
22
22
  ]
23
23
 
@@ -201,3 +201,152 @@ def test_resume_skips_already_done_pages_under_concurrency(tmp_path, monkeypatch
201
201
  assert sorted(r["page"] for r in rows) == [1, 2, 3]
202
202
  descriptions_by_page = {r["page"]: r["description"] for r in rows}
203
203
  assert descriptions_by_page[2] == "already done" # untouched, not re-described
204
+
205
+
206
+ def test_rows_record_vision_provider_and_model(tmp_path):
207
+ pdf_path = _make_pdf(tmp_path / "doc.pdf", num_pages=1)
208
+
209
+ figure_describer.describe_figures_for_new_pdfs(
210
+ new_pdf_paths=[pdf_path],
211
+ output_dir=str(tmp_path),
212
+ vision_provider="gpt",
213
+ vision_api_key="sk-test",
214
+ vision_model="gpt-5.4",
215
+ max_workers=1,
216
+ context_pages=0,
217
+ )
218
+
219
+ rows = _read_jsonl(tmp_path / "02_visuals_kb.jsonl")
220
+ assert len(rows) == 1
221
+ assert rows[0]["vision_provider"] == "gpt"
222
+ assert rows[0]["vision_model"] == "gpt-5.4"
223
+
224
+
225
+ def test_resume_with_different_vision_model_skips_document_not_mixed(tmp_path, monkeypatch):
226
+ """Regression test for active prevention: a document already has a page
227
+ described under one vision_model; resuming with a different one must not
228
+ silently mix the two under one document. The document is skipped in full
229
+ (not partially re-described) and its basename is reported back."""
230
+ figures_path = tmp_path / "02_visuals_kb.jsonl"
231
+ pdf_path = _make_pdf(tmp_path / "doc.pdf", num_pages=2)
232
+
233
+ figures_path.write_text(
234
+ json.dumps({
235
+ "source": "doc.pdf", "page": 1, "document_id": "x", "figure_index": 0,
236
+ "figure_id": "x:p1:f0", "description": "old",
237
+ "vision_provider": "gpt", "vision_model": "gpt-5.4",
238
+ }) + "\n",
239
+ encoding="utf-8",
240
+ )
241
+
242
+ called = []
243
+
244
+ def fake(images, prompt, provider, api_key, model, detail, reasoning_effort):
245
+ called.append(True)
246
+ return json.dumps([{"description": "new"}])
247
+
248
+ monkeypatch.setattr(figure_describer, "call_vision_llm", fake)
249
+
250
+ skipped = figure_describer.describe_figures_for_new_pdfs(
251
+ new_pdf_paths=[pdf_path],
252
+ output_dir=str(tmp_path),
253
+ vision_provider="gpt",
254
+ vision_api_key="sk-test",
255
+ vision_model="gpt-5.5", # different from the existing row's gpt-5.4
256
+ max_workers=1,
257
+ context_pages=0,
258
+ )
259
+
260
+ assert skipped == ["doc.pdf"]
261
+ assert called == [] # page 2 was never even attempted
262
+ rows = _read_jsonl(figures_path)
263
+ assert len(rows) == 1 # only the original row -- nothing added, nothing mixed in
264
+ assert rows[0]["vision_model"] == "gpt-5.4"
265
+
266
+
267
+ def test_resume_with_same_vision_model_proceeds_normally(tmp_path, monkeypatch):
268
+ figures_path = tmp_path / "02_visuals_kb.jsonl"
269
+ pdf_path = _make_pdf(tmp_path / "doc.pdf", num_pages=2)
270
+
271
+ figures_path.write_text(
272
+ json.dumps({
273
+ "source": "doc.pdf", "page": 1, "document_id": "x", "figure_index": 0,
274
+ "figure_id": "x:p1:f0", "description": "old",
275
+ "vision_provider": "gpt", "vision_model": "gpt-5.4",
276
+ }) + "\n",
277
+ encoding="utf-8",
278
+ )
279
+
280
+ skipped = figure_describer.describe_figures_for_new_pdfs(
281
+ new_pdf_paths=[pdf_path],
282
+ output_dir=str(tmp_path),
283
+ vision_provider="gpt",
284
+ vision_api_key="sk-test",
285
+ vision_model="gpt-5.4", # same as the existing row
286
+ max_workers=1,
287
+ context_pages=0,
288
+ )
289
+
290
+ assert skipped == []
291
+ rows = _read_jsonl(figures_path)
292
+ assert sorted(r["page"] for r in rows) == [1, 2]
293
+
294
+
295
+ def test_resume_with_no_recorded_model_does_not_block(tmp_path):
296
+ """Pre-upgrade rows have no vision_model field at all -- that must be
297
+ treated as unknown provenance, not a mismatch, per HANDOFF's
298
+ compatibility note that a missing block just means 'details unknown'."""
299
+ figures_path = tmp_path / "02_visuals_kb.jsonl"
300
+ pdf_path = _make_pdf(tmp_path / "doc.pdf", num_pages=2)
301
+
302
+ figures_path.write_text(
303
+ json.dumps({
304
+ "source": "doc.pdf", "page": 1, "document_id": "x", "figure_index": 0,
305
+ "figure_id": "x:p1:f0", "description": "old",
306
+ }) + "\n",
307
+ encoding="utf-8",
308
+ )
309
+
310
+ skipped = figure_describer.describe_figures_for_new_pdfs(
311
+ new_pdf_paths=[pdf_path],
312
+ output_dir=str(tmp_path),
313
+ vision_provider="gpt",
314
+ vision_api_key="sk-test",
315
+ vision_model="gpt-5.5",
316
+ max_workers=1,
317
+ context_pages=0,
318
+ )
319
+
320
+ assert skipped == []
321
+ rows = _read_jsonl(figures_path)
322
+ assert sorted(r["page"] for r in rows) == [1, 2]
323
+
324
+
325
+ def test_mismatch_in_one_document_does_not_block_others(tmp_path):
326
+ figures_path = tmp_path / "02_visuals_kb.jsonl"
327
+ mismatched_pdf = _make_pdf(tmp_path / "mismatched.pdf", num_pages=1)
328
+ clean_pdf = _make_pdf(tmp_path / "clean.pdf", num_pages=1)
329
+
330
+ figures_path.write_text(
331
+ json.dumps({
332
+ "source": "mismatched.pdf", "page": 1, "document_id": "x", "figure_index": 0,
333
+ "figure_id": "x:p1:f0", "description": "old",
334
+ "vision_provider": "gpt", "vision_model": "gpt-5.4",
335
+ }) + "\n",
336
+ encoding="utf-8",
337
+ )
338
+
339
+ skipped = figure_describer.describe_figures_for_new_pdfs(
340
+ new_pdf_paths=[mismatched_pdf, clean_pdf],
341
+ output_dir=str(tmp_path),
342
+ vision_provider="gpt",
343
+ vision_api_key="sk-test",
344
+ vision_model="gpt-5.5",
345
+ max_workers=2,
346
+ context_pages=0,
347
+ )
348
+
349
+ assert skipped == ["mismatched.pdf"]
350
+ rows = _read_jsonl(figures_path)
351
+ sources_written = {r["source"] for r in rows if r.get("vision_model") == "gpt-5.5"}
352
+ assert sources_written == {"clean.pdf"}
@@ -145,7 +145,7 @@ def test_describe_images_skips_non_string_description(tmp_path, monkeypatch):
145
145
  )
146
146
 
147
147
  assert written == []
148
- assert _read_jsonl(tmp_path / "image_descriptions.jsonl") == []
148
+ assert _read_jsonl(tmp_path / image_describer.IMAGE_DESCRIPTIONS_FILE) == []
149
149
 
150
150
 
151
151
  def test_describe_images_writes_rows_for_each_image(tmp_path, monkeypatch):
@@ -166,11 +166,13 @@ def test_describe_images_writes_rows_for_each_image(tmp_path, monkeypatch):
166
166
  )
167
167
 
168
168
  assert set(written) == {"a.png", "b.png"}
169
- rows = _read_jsonl(tmp_path / "image_descriptions.jsonl")
169
+ rows = _read_jsonl(tmp_path / image_describer.IMAGE_DESCRIPTIONS_FILE)
170
170
  assert {r["source"] for r in rows} == {"a.png", "b.png"}
171
171
  for row in rows:
172
172
  assert row["title"] == "Something"
173
173
  assert len(row["image_id"]) == 16
174
+ assert row["vision_provider"] == "gpt"
175
+ assert row["vision_model"] == "gpt-5.4"
174
176
 
175
177
 
176
178
  def test_failed_image_is_not_marked_written(tmp_path, monkeypatch):
@@ -195,7 +197,7 @@ def test_failed_image_is_not_marked_written(tmp_path, monkeypatch):
195
197
  )
196
198
 
197
199
  assert written == ["good.png"]
198
- rows = _read_jsonl(tmp_path / "image_descriptions.jsonl")
200
+ rows = _read_jsonl(tmp_path / image_describer.IMAGE_DESCRIPTIONS_FILE)
199
201
  assert {r["source"] for r in rows} == {"good.png"}
200
202
 
201
203
 
@@ -223,7 +225,7 @@ def test_vision_call_exception_is_not_marked_written(tmp_path, monkeypatch):
223
225
  )
224
226
 
225
227
  assert len(written) == 1
226
- rows = _read_jsonl(tmp_path / "image_descriptions.jsonl")
228
+ rows = _read_jsonl(tmp_path / image_describer.IMAGE_DESCRIPTIONS_FILE)
227
229
  assert len(rows) == 1
228
230
 
229
231
 
@@ -231,7 +233,7 @@ def test_resume_skips_already_done_images(tmp_path, monkeypatch):
231
233
  img1 = _make_png(tmp_path / "a.png")
232
234
  img2 = _make_png(tmp_path / "b.png")
233
235
 
234
- descriptions_path = tmp_path / "image_descriptions.jsonl"
236
+ descriptions_path = tmp_path / image_describer.IMAGE_DESCRIPTIONS_FILE
235
237
  descriptions_path.write_text(
236
238
  json.dumps({"source": "a.png", "image_id": "x", "title": "Old", "description": "already done"}) + "\n",
237
239
  encoding="utf-8",
@@ -0,0 +1,102 @@
1
+ import os
2
+
3
+ from visual_parser.pdf_tracker import (
4
+ find_new_pdfs,
5
+ load_processed_pdfs,
6
+ mark_as_processed,
7
+ unmark_as_processed,
8
+ )
9
+
10
+
11
+ def test_unmark_removes_a_match(tmp_path):
12
+ registry = tmp_path / "04_processed_pdfs.txt"
13
+ mark_as_processed(str(registry), ["a.pdf", "b.pdf"])
14
+
15
+ removed = unmark_as_processed(str(registry), ["a.pdf"])
16
+
17
+ assert removed == 1
18
+ assert load_processed_pdfs(str(registry)) == ["b.pdf"]
19
+
20
+
21
+ def test_unmark_is_case_sensitive(tmp_path):
22
+ """Matches load_processed_pdfs/mark_as_processed's own convention --
23
+ these are what actually govern registry contents."""
24
+ registry = tmp_path / "04_processed_pdfs.txt"
25
+ mark_as_processed(str(registry), ["Doc.PDF"])
26
+
27
+ removed = unmark_as_processed(str(registry), ["doc.pdf"])
28
+
29
+ assert removed == 0
30
+ assert load_processed_pdfs(str(registry)) == ["Doc.PDF"]
31
+
32
+
33
+ def test_unmark_noop_on_missing_registry(tmp_path):
34
+ registry = tmp_path / "04_processed_pdfs.txt"
35
+ assert unmark_as_processed(str(registry), ["a.pdf"]) == 0
36
+ assert not registry.exists()
37
+
38
+
39
+ def test_unmark_does_not_rewrite_when_nothing_matches(tmp_path, monkeypatch):
40
+ registry = tmp_path / "04_processed_pdfs.txt"
41
+ mark_as_processed(str(registry), ["a.pdf"])
42
+
43
+ import visual_parser.pdf_tracker as pdf_tracker
44
+
45
+ def fail_if_called(*a, **k):
46
+ raise AssertionError("save_processed_pdfs should not be called when nothing matched")
47
+
48
+ monkeypatch.setattr(pdf_tracker, "save_processed_pdfs", fail_if_called)
49
+
50
+ removed = unmark_as_processed(str(registry), ["not-in-registry.pdf"])
51
+ assert removed == 0
52
+
53
+
54
+ def test_find_new_pdfs_uses_explicit_registry_path_when_given(tmp_path):
55
+ """Regression test for the input_dir/output_dir registry mismatch: when
56
+ output_dir differs from input_dir, the registry actually lives in
57
+ output_dir, so an explicit registry_path must be honored instead of the
58
+ input_dir-joined default."""
59
+ input_dir = tmp_path / "in"
60
+ output_dir = tmp_path / "out"
61
+ input_dir.mkdir()
62
+ output_dir.mkdir()
63
+ (input_dir / "a.pdf").write_bytes(b"stub")
64
+
65
+ real_registry = output_dir / "04_processed_pdfs.txt"
66
+ mark_as_processed(str(real_registry), ["a.pdf"])
67
+
68
+ new_pdfs = find_new_pdfs(str(input_dir), registry_path=str(real_registry))
69
+
70
+ assert new_pdfs == []
71
+
72
+
73
+ def test_find_new_pdfs_default_registry_path_unchanged(tmp_path):
74
+ """Backward compatibility: omitting registry_path must keep resolving the
75
+ registry against input_dir, exactly as before -- this is what RADIANT-LLM
76
+ and any other same-folder caller relies on."""
77
+ input_dir = tmp_path / "in"
78
+ input_dir.mkdir()
79
+ (input_dir / "a.pdf").write_bytes(b"stub")
80
+
81
+ registry_in_input_dir = input_dir / "04_processed_pdfs.txt"
82
+ mark_as_processed(str(registry_in_input_dir), ["a.pdf"])
83
+
84
+ new_pdfs = find_new_pdfs(str(input_dir))
85
+
86
+ assert new_pdfs == []
87
+
88
+
89
+ def test_find_new_pdfs_rebuild_ignores_registry_path(tmp_path):
90
+ input_dir = tmp_path / "in"
91
+ output_dir = tmp_path / "out"
92
+ input_dir.mkdir()
93
+ output_dir.mkdir()
94
+ pdf_path = input_dir / "a.pdf"
95
+ pdf_path.write_bytes(b"stub")
96
+
97
+ real_registry = output_dir / "04_processed_pdfs.txt"
98
+ mark_as_processed(str(real_registry), ["a.pdf"])
99
+
100
+ new_pdfs = find_new_pdfs(str(input_dir), rebuild=True, registry_path=str(real_registry))
101
+
102
+ assert new_pdfs == [str(pdf_path)]