visual-parser 2.2.0__tar.gz → 2.2.2__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.0/visual_parser.egg-info → visual_parser-2.2.2}/PKG-INFO +9 -5
  2. {visual_parser-2.2.0 → visual_parser-2.2.2}/README.md +8 -4
  3. {visual_parser-2.2.0 → visual_parser-2.2.2}/pyproject.toml +1 -1
  4. {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_cli_parity.py +24 -0
  5. {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_config.py +37 -0
  6. {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_figure_describer.py +27 -0
  7. visual_parser-2.2.2/tests/test_image_describer.py +328 -0
  8. visual_parser-2.2.2/tests/test_image_tracker.py +79 -0
  9. visual_parser-2.2.2/tests/test_jsonl_writer.py +61 -0
  10. visual_parser-2.2.2/tests/test_kb_redo.py +85 -0
  11. visual_parser-2.2.2/tests/test_metadata_extractor.py +99 -0
  12. visual_parser-2.2.2/tests/test_pdf_tracker.py +48 -0
  13. visual_parser-2.2.2/tests/test_pipeline.py +461 -0
  14. visual_parser-2.2.2/tests/test_prompts.py +24 -0
  15. {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_vision_llm.py +27 -0
  16. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/__init__.py +1 -1
  17. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/cli.py +51 -9
  18. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/cli_main.py +50 -9
  19. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/config.py +37 -2
  20. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/figure_describer.py +14 -1
  21. visual_parser-2.2.2/visual_parser/image_describer.py +329 -0
  22. visual_parser-2.2.2/visual_parser/image_tracker.py +71 -0
  23. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/jsonl_writer.py +64 -3
  24. visual_parser-2.2.2/visual_parser/kb_redo.py +148 -0
  25. visual_parser-2.2.2/visual_parser/metadata_extractor.py +168 -0
  26. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/pdf_tracker.py +25 -0
  27. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/pipeline.py +139 -24
  28. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/prompts.py +78 -0
  29. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/text_extractor.py +20 -29
  30. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/vision_llm.py +46 -22
  31. {visual_parser-2.2.0 → visual_parser-2.2.2/visual_parser.egg-info}/PKG-INFO +9 -5
  32. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/SOURCES.txt +10 -0
  33. visual_parser-2.2.0/tests/test_pipeline.py +0 -168
  34. visual_parser-2.2.0/visual_parser/metadata_extractor.py +0 -94
  35. {visual_parser-2.2.0 → visual_parser-2.2.2}/LICENSE +0 -0
  36. {visual_parser-2.2.0 → visual_parser-2.2.2}/setup.cfg +0 -0
  37. {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_integration.py +0 -0
  38. {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_model_catalog.py +0 -0
  39. {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_ollama_local.py +0 -0
  40. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/__main__.py +0 -0
  41. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/model_catalog.py +0 -0
  42. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/nougat_engine.py +0 -0
  43. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/ollama_local.py +0 -0
  44. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/openai_gateway.py +0 -0
  45. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/dependency_links.txt +0 -0
  46. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/entry_points.txt +0 -0
  47. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/requires.txt +0 -0
  48. {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visual-parser
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: Standalone Visual-RAG PDF Parser - text extraction and Vision-LLM figure descriptions to JSONL
5
5
  Author: Zavier N. Ndum
6
6
  Author-email: zavier.ndum@tamu.edu
@@ -50,7 +50,7 @@ Dynamic: license-file
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 into a multi-modal JSONL knowledge base (text chunks, figure 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-ingestion tool, independent of any chatbot.
54
54
 
55
55
  1) Run `visual-parser` on curated PDFs to generate JSONL KB files.
56
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.
@@ -72,10 +72,11 @@ Dynamic: license-file
72
72
 
73
73
  By default, the pipeline writes:
74
74
  - `01_chunks_kb.jsonl`: chunked text extracted from PDFs (Nougat by default).
75
- - `02_visuals_kb.jsonl`: figure/page visual descriptions (Vision LLM).
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
78
 
78
- Alongside the KB, the pipeline also writes two bookkeeping files: `04_processed_pdfs.txt` (tracks which PDFs have already been processed, so re-runs skip them unless `--rebuild`) and `05_pipeline.log` (run log at the verbosity set by `--log-level`, default `ERROR`).
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`).
79
80
 
80
81
  ## API Keys (.env)
81
82
 
@@ -262,6 +263,7 @@ Text extraction:
262
263
  - `--nougat-model facebook/nougat-small`
263
264
  - `--chunk-size 500`
264
265
  - `--chunk-overlap 100`
266
+ - `--nougat-crop-margin` (off by default — lets Nougat autocrop whitespace margins instead of this package's long-standing uncropped behavior; autocropping can silently crop into real content on unusual page layouts)
265
267
 
266
268
  Vision LLM:
267
269
  - `--vision-provider gpt|gemini|ollama` (default: `gpt`) — see [Local Ollama vision provider](#local-ollama-vision-provider) for the `ollama` option
@@ -273,8 +275,10 @@ Vision LLM:
273
275
 
274
276
  Performance / misc:
275
277
  - `--max-workers 4`
276
- - `--rebuild` (reprocess everything; ignore `04_processed_pdfs.txt`)
278
+ - `--rebuild` (reprocess everything; ignore `04_processed_pdfs.txt`/`parsed_images.txt`)
279
+ - `--redo NAME` (surgically clear one PDF/image's rows from every KB file + both registries, so it's reprocessed fresh on the next run — a targeted alternative to `--rebuild`)
277
280
  - `--skip-text` (skip text extraction and resume only the vision steps; use after an interrupted run)
281
+ - `--skip-images` (don't process standalone images in `--input-dir`, PDFs only)
278
282
  - `--list-models` — print the live, currently-available vision models for whichever provider(s) you have a key configured for, then exit (no PDF processing). Reflects Portkey's catalog too when that's active.
279
283
  - `--version` / `-V`
280
284
  - `--log-level DEBUG|INFO|WARNING|ERROR` (default: `ERROR`)
@@ -5,7 +5,7 @@
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 into a multi-modal JSONL knowledge base (text chunks, figure 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-ingestion tool, independent of any chatbot.
9
9
 
10
10
  1) Run `visual-parser` on curated PDFs to generate JSONL KB files.
11
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.
@@ -27,10 +27,11 @@
27
27
 
28
28
  By default, the pipeline writes:
29
29
  - `01_chunks_kb.jsonl`: chunked text extracted from PDFs (Nougat by default).
30
- - `02_visuals_kb.jsonl`: figure/page visual descriptions (Vision LLM).
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
33
 
33
- Alongside the KB, the pipeline also writes two bookkeeping files: `04_processed_pdfs.txt` (tracks which PDFs have already been processed, so re-runs skip them unless `--rebuild`) and `05_pipeline.log` (run log at the verbosity set by `--log-level`, default `ERROR`).
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`).
34
35
 
35
36
  ## API Keys (.env)
36
37
 
@@ -217,6 +218,7 @@ Text extraction:
217
218
  - `--nougat-model facebook/nougat-small`
218
219
  - `--chunk-size 500`
219
220
  - `--chunk-overlap 100`
221
+ - `--nougat-crop-margin` (off by default — lets Nougat autocrop whitespace margins instead of this package's long-standing uncropped behavior; autocropping can silently crop into real content on unusual page layouts)
220
222
 
221
223
  Vision LLM:
222
224
  - `--vision-provider gpt|gemini|ollama` (default: `gpt`) — see [Local Ollama vision provider](#local-ollama-vision-provider) for the `ollama` option
@@ -228,8 +230,10 @@ Vision LLM:
228
230
 
229
231
  Performance / misc:
230
232
  - `--max-workers 4`
231
- - `--rebuild` (reprocess everything; ignore `04_processed_pdfs.txt`)
233
+ - `--rebuild` (reprocess everything; ignore `04_processed_pdfs.txt`/`parsed_images.txt`)
234
+ - `--redo NAME` (surgically clear one PDF/image's rows from every KB file + both registries, so it's reprocessed fresh on the next run — a targeted alternative to `--rebuild`)
232
235
  - `--skip-text` (skip text extraction and resume only the vision steps; use after an interrupted run)
236
+ - `--skip-images` (don't process standalone images in `--input-dir`, PDFs only)
233
237
  - `--list-models` — print the live, currently-available vision models for whichever provider(s) you have a key configured for, then exit (no PDF processing). Reflects Portkey's catalog too when that's active.
234
238
  - `--version` / `-V`
235
239
  - `--log-level DEBUG|INFO|WARNING|ERROR` (default: `ERROR`)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "visual-parser"
7
- version = "2.2.0"
7
+ version = "2.2.2"
8
8
  description = "Standalone Visual-RAG PDF Parser - text extraction and Vision-LLM figure descriptions to JSONL"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -34,6 +34,30 @@ def test_cli_and_cli_main_have_identical_flag_structure():
34
34
  assert a1.option_strings == a2.option_strings, f"--{dest}: flag spelling mismatch"
35
35
 
36
36
 
37
+ def test_help_text_image_mentions_match():
38
+ """cli.py and cli_main.py deliberately use different unicode-vs-ASCII
39
+ characters in help text (that's the whole reason cli_main.py exists --
40
+ see its module docstring), so help strings aren't expected to be
41
+ byte-identical, and the structural parity test above doesn't compare
42
+ them at all. But one file's help text documenting a real capability
43
+ (e.g. standalone-image support) that the other omits entirely is a real
44
+ content drift, not a cosmetic one -- this is a regression test for
45
+ exactly that: --input-dir's help text in cli.py forgot to mention images
46
+ when --skip-images was added to both files' flag sets."""
47
+ actions1 = _actions_by_dest(cli._build_arg_parser())
48
+ actions2 = _actions_by_dest(cli_main._build_arg_parser())
49
+
50
+ for dest in actions1:
51
+ if dest not in actions2:
52
+ continue
53
+ mentions1 = "image" in (actions1[dest].help or "").lower()
54
+ mentions2 = "image" in (actions2[dest].help or "").lower()
55
+ assert mentions1 == mentions2, (
56
+ f"--{dest}: one file's help text mentions images, the other doesn't "
57
+ f"(cli={mentions1}, cli_main={mentions2})"
58
+ )
59
+
60
+
37
61
  @pytest.mark.parametrize("module_name", sorted(MODULES))
38
62
  def test_missing_input_dir_errors_unless_list_models(module_name):
39
63
  module = MODULES[module_name]
@@ -36,6 +36,43 @@ def test_from_env_reads_overrides(monkeypatch):
36
36
  assert config.vision_context_pages == 2
37
37
 
38
38
 
39
+ def test_skip_images_defaults_to_false():
40
+ assert ParserConfig(input_dir=".").skip_images is False
41
+
42
+
43
+ def test_from_env_reads_skip_images(monkeypatch):
44
+ monkeypatch.setenv("VISUAL_PARSER_SKIP_IMAGES", "true")
45
+ assert ParserConfig.from_env().skip_images is True
46
+
47
+
48
+ def test_redo_names_defaults_to_empty_list():
49
+ assert ParserConfig(input_dir=".").redo_names == []
50
+
51
+
52
+ def test_from_env_redo_names_defaults_to_empty_list(monkeypatch):
53
+ monkeypatch.delenv("VISUAL_PARSER_REDO_NAMES", raising=False)
54
+ assert ParserConfig.from_env().redo_names == []
55
+
56
+
57
+ def test_from_env_reads_redo_names_comma_separated(monkeypatch):
58
+ monkeypatch.setenv("VISUAL_PARSER_REDO_NAMES", "doc1.pdf, diagram2.png ,doc3.pdf")
59
+ assert ParserConfig.from_env().redo_names == ["doc1.pdf", "diagram2.png", "doc3.pdf"]
60
+
61
+
62
+ def test_nougat_crop_margin_defaults_to_false():
63
+ assert ParserConfig(input_dir=".").nougat_crop_margin is False
64
+
65
+
66
+ def test_from_env_nougat_crop_margin_defaults_to_false(monkeypatch):
67
+ monkeypatch.delenv("VISUAL_PARSER_NOUGAT_CROP_MARGIN", raising=False)
68
+ assert ParserConfig.from_env().nougat_crop_margin is False
69
+
70
+
71
+ def test_from_env_reads_nougat_crop_margin(monkeypatch):
72
+ monkeypatch.setenv("VISUAL_PARSER_NOUGAT_CROP_MARGIN", "true")
73
+ assert ParserConfig.from_env().nougat_crop_margin is True
74
+
75
+
39
76
  def test_vision_context_pages_defaults_to_zero(monkeypatch):
40
77
  monkeypatch.delenv("VISUAL_PARSER_VISION_CONTEXT_PAGES", raising=False)
41
78
  assert ParserConfig.from_env().vision_context_pages == 0
@@ -60,6 +60,33 @@ def test_context_pages_zero_sends_one_image_per_call(tmp_path, monkeypatch):
60
60
  assert {r["page"] for r in rows} == {1, 2, 3}
61
61
 
62
62
 
63
+ def test_non_string_description_is_skipped_not_written(tmp_path, monkeypatch):
64
+ """Regression test: a weak/non-compliant model can return 'description'
65
+ as a nested object instead of the documented flat string. Before the
66
+ isinstance(description, str) fix, this silently wrote the dict straight
67
+ into 02_visuals_kb.jsonl's 'description' field with no warning at all --
68
+ unlike image_describer.py's sibling bug, this one never crashed, so it
69
+ needs its own explicit check that the row is actually dropped."""
70
+ pdf_path = _make_pdf(tmp_path / "doc.pdf", num_pages=1)
71
+
72
+ def fake(images, prompt, provider, api_key, model, detail, reasoning_effort):
73
+ return json.dumps([{"description": {"Subject": "nested, not flat"}}])
74
+
75
+ monkeypatch.setattr(figure_describer, "call_vision_llm", fake)
76
+
77
+ figure_describer.describe_figures_for_new_pdfs(
78
+ new_pdf_paths=[pdf_path],
79
+ output_dir=str(tmp_path),
80
+ vision_provider="gpt",
81
+ vision_api_key="sk-test",
82
+ vision_model="gpt-5.4",
83
+ max_workers=1,
84
+ context_pages=0,
85
+ )
86
+
87
+ assert _read_jsonl(tmp_path / "02_visuals_kb.jsonl") == []
88
+
89
+
63
90
  def test_context_pages_window_bounded_at_pdf_edges(tmp_path, monkeypatch):
64
91
  pdf_path = _make_pdf(tmp_path / "doc.pdf", num_pages=3)
65
92
  captured = {} # page_number -> image_count
@@ -0,0 +1,328 @@
1
+ import json
2
+
3
+ import pytest
4
+ from PIL import Image
5
+
6
+ from visual_parser import image_describer
7
+
8
+
9
+ def _make_png(path, color=(255, 0, 0)):
10
+ img = Image.new("RGB", (8, 8), color=color)
11
+ img.save(str(path), format="PNG")
12
+ return str(path)
13
+
14
+
15
+ def _read_jsonl(path):
16
+ if not path.exists():
17
+ return []
18
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # _parse_single_image_response
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def test_parse_well_formed_object():
26
+ raw = json.dumps({"description": "**Subject:** A Chart"})
27
+ parsed = image_describer._parse_single_image_response(raw, "img.png")
28
+ assert parsed == {"description": "**Subject:** A Chart"}
29
+
30
+
31
+ def test_parse_prose_wrapped_object():
32
+ raw = 'Sure, here is the result:\n```json\n{"description": "**Subject:** X"}\n```\nHope that helps!'
33
+ parsed = image_describer._parse_single_image_response(raw, "img.png")
34
+ assert parsed == {"description": "**Subject:** X"}
35
+
36
+
37
+ def test_parse_malformed_returns_none():
38
+ parsed = image_describer._parse_single_image_response("not json at all {{{", "img.png")
39
+ assert parsed is None
40
+
41
+
42
+ def test_parse_unwraps_single_element_list():
43
+ """A model that ignores 'NOT a list' and returns [{...}] anyway should
44
+ still be recovered, mirroring figure_describer.py's own defensive
45
+ normalization for the opposite shape mismatch."""
46
+ raw = json.dumps([{"description": "**Subject:** Y"}])
47
+ parsed = image_describer._parse_single_image_response(raw, "img.png")
48
+ assert parsed == {"description": "**Subject:** Y"}
49
+
50
+
51
+ def test_parse_recovers_real_answer_when_model_echoes_the_prompt_template():
52
+ """Regression test: a greedy regex would span from the first '{' to the
53
+ LAST '}' across both blocks, producing one invalid blob when a model
54
+ echoes the prompt's own OUTPUT FORMAT example alongside its real answer.
55
+ The real answer appears first here, so it must be the one recovered."""
56
+ raw = (
57
+ 'Here is my answer:\n'
58
+ '{"description": "**Subject:** Real Answer"}\n'
59
+ 'Reminder of the format: { "description": "**Subject:** [Title]..." }'
60
+ )
61
+ parsed = image_describer._parse_single_image_response(raw, "img.png")
62
+ assert parsed == {"description": "**Subject:** Real Answer"}
63
+
64
+
65
+ def test_parse_handles_literal_brace_inside_description_text():
66
+ """A naive lazy regex would stop at the first '}' even if it's just a
67
+ character inside a string value (e.g. set notation in the description),
68
+ truncating the object early and failing to parse. A proper JSON-aware
69
+ scan must not have this problem. Wrapped in prose so json.loads(body)
70
+ fails directly and the fallback scan path is actually exercised."""
71
+ raw = 'Answer:\n{"description": "Shows the set {A, B, C} on the x-axis"}\nDone.'
72
+ parsed = image_describer._parse_single_image_response(raw, "img.png")
73
+ assert parsed == {"description": "Shows the set {A, B, C} on the x-axis"}
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # _flatten_to_rgb
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def test_flatten_composites_transparency_onto_white():
81
+ """Regression test: Image.convert('RGB') alone drops alpha without
82
+ compositing, commonly exposing (0,0,0) stored under fully-transparent
83
+ pixels -- a transparent-background PNG would silently turn black."""
84
+ img = Image.new("RGBA", (4, 4), (0, 0, 0, 0)) # fully transparent, black underneath
85
+ img.putpixel((0, 0), (10, 20, 30, 255)) # one opaque pixel
86
+
87
+ flattened = image_describer._flatten_to_rgb(img)
88
+
89
+ assert flattened.mode == "RGB"
90
+ assert flattened.getpixel((1, 1)) == (255, 255, 255) # transparent area -> white, not black
91
+ assert flattened.getpixel((0, 0)) == (10, 20, 30) # opaque pixel preserved
92
+
93
+
94
+ def test_flatten_leaves_opaque_rgb_image_unchanged():
95
+ img = Image.new("RGB", (4, 4), (10, 20, 30))
96
+ flattened = image_describer._flatten_to_rgb(img)
97
+ assert flattened.mode == "RGB"
98
+ assert flattened.getpixel((0, 0)) == (10, 20, 30)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # _derive_title
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def test_derive_title_extracts_subject_heading():
106
+ description = "**Subject:** Vertical Parabolic Gate Schematic\n**Verbatim Text Content:** ..."
107
+ assert image_describer._derive_title(description) == "Vertical Parabolic Gate Schematic"
108
+
109
+
110
+ def test_derive_title_falls_back_to_first_sentence():
111
+ description = "A plot of decay heat over time. More detail follows here."
112
+ assert image_describer._derive_title(description) == "A plot of decay heat over time."
113
+
114
+
115
+ def test_derive_title_hard_truncates_as_last_resort():
116
+ description = "x" * 300 # no sentence boundary, no Subject heading
117
+ title = image_describer._derive_title(description)
118
+ assert title == "x" * 100
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # describe_images
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def test_describe_images_skips_non_string_description(tmp_path, monkeypatch):
126
+ """Regression test: a weak/non-compliant model can return 'description'
127
+ as a nested object instead of the documented flat string (e.g. one field
128
+ per heading instead of folding them into one string). Before the
129
+ isinstance(description, str) fix, this crashed _derive_title()'s regex
130
+ search with 'TypeError: expected string or bytes-like object, got dict'
131
+ instead of being skipped like a missing field."""
132
+ img = _make_png(tmp_path / "a.png")
133
+
134
+ monkeypatch.setattr(
135
+ image_describer, "call_vision_llm",
136
+ lambda **kwargs: json.dumps({"description": {"Subject": "nested, not flat"}}),
137
+ )
138
+
139
+ written = image_describer.describe_images(
140
+ new_image_paths=[img],
141
+ output_dir=str(tmp_path),
142
+ vision_provider="gpt",
143
+ vision_api_key="sk-test",
144
+ vision_model="gpt-5.4",
145
+ )
146
+
147
+ assert written == []
148
+ assert _read_jsonl(tmp_path / "image_descriptions.jsonl") == []
149
+
150
+
151
+ def test_describe_images_writes_rows_for_each_image(tmp_path, monkeypatch):
152
+ img1 = _make_png(tmp_path / "a.png")
153
+ img2 = _make_png(tmp_path / "b.png")
154
+
155
+ monkeypatch.setattr(
156
+ image_describer, "call_vision_llm",
157
+ lambda **kwargs: json.dumps({"description": "**Subject:** Something"}),
158
+ )
159
+
160
+ written = image_describer.describe_images(
161
+ new_image_paths=[img1, img2],
162
+ output_dir=str(tmp_path),
163
+ vision_provider="gpt",
164
+ vision_api_key="sk-test",
165
+ vision_model="gpt-5.4",
166
+ )
167
+
168
+ assert set(written) == {"a.png", "b.png"}
169
+ rows = _read_jsonl(tmp_path / "image_descriptions.jsonl")
170
+ assert {r["source"] for r in rows} == {"a.png", "b.png"}
171
+ for row in rows:
172
+ assert row["title"] == "Something"
173
+ assert len(row["image_id"]) == 16
174
+
175
+
176
+ def test_failed_image_is_not_marked_written(tmp_path, monkeypatch):
177
+ """A failed vision call must not be reported as written -- the caller
178
+ uses the return value to decide what to mark processed, and a failed
179
+ image must stay eligible for retry on the next run."""
180
+ good = _make_png(tmp_path / "good.png")
181
+ corrupt = tmp_path / "corrupt.png"
182
+ corrupt.write_bytes(b"this is not a real image")
183
+
184
+ monkeypatch.setattr(
185
+ image_describer, "call_vision_llm",
186
+ lambda **kwargs: json.dumps({"description": "**Subject:** Fine"}),
187
+ )
188
+
189
+ written = image_describer.describe_images(
190
+ new_image_paths=[good, str(corrupt)],
191
+ output_dir=str(tmp_path),
192
+ vision_provider="gpt",
193
+ vision_api_key="sk-test",
194
+ vision_model="gpt-5.4",
195
+ )
196
+
197
+ assert written == ["good.png"]
198
+ rows = _read_jsonl(tmp_path / "image_descriptions.jsonl")
199
+ assert {r["source"] for r in rows} == {"good.png"}
200
+
201
+
202
+ def test_vision_call_exception_is_not_marked_written(tmp_path, monkeypatch):
203
+ img1 = _make_png(tmp_path / "a.png")
204
+ img2 = _make_png(tmp_path / "b.png")
205
+
206
+ call_count = {"n": 0}
207
+
208
+ def fake(**kwargs):
209
+ call_count["n"] += 1
210
+ if call_count["n"] == 1:
211
+ raise RuntimeError("simulated failure")
212
+ return json.dumps({"description": "**Subject:** OK"})
213
+
214
+ monkeypatch.setattr(image_describer, "call_vision_llm", fake)
215
+
216
+ written = image_describer.describe_images(
217
+ new_image_paths=[img1, img2],
218
+ output_dir=str(tmp_path),
219
+ vision_provider="gpt",
220
+ vision_api_key="sk-test",
221
+ vision_model="gpt-5.4",
222
+ max_workers=1, # deterministic call order
223
+ )
224
+
225
+ assert len(written) == 1
226
+ rows = _read_jsonl(tmp_path / "image_descriptions.jsonl")
227
+ assert len(rows) == 1
228
+
229
+
230
+ def test_resume_skips_already_done_images(tmp_path, monkeypatch):
231
+ img1 = _make_png(tmp_path / "a.png")
232
+ img2 = _make_png(tmp_path / "b.png")
233
+
234
+ descriptions_path = tmp_path / "image_descriptions.jsonl"
235
+ descriptions_path.write_text(
236
+ json.dumps({"source": "a.png", "image_id": "x", "title": "Old", "description": "already done"}) + "\n",
237
+ encoding="utf-8",
238
+ )
239
+
240
+ called = []
241
+ monkeypatch.setattr(
242
+ image_describer, "call_vision_llm",
243
+ lambda **kwargs: called.append(True) or json.dumps({"description": "**Subject:** New"}),
244
+ )
245
+
246
+ written = image_describer.describe_images(
247
+ new_image_paths=[img1, img2],
248
+ output_dir=str(tmp_path),
249
+ vision_provider="gpt",
250
+ vision_api_key="sk-test",
251
+ vision_model="gpt-5.4",
252
+ )
253
+
254
+ assert len(called) == 1 # only b.png was actually called
255
+ assert written == ["b.png"]
256
+ rows = _read_jsonl(descriptions_path)
257
+ assert {r["source"] for r in rows} == {"a.png", "b.png"}
258
+ assert next(r for r in rows if r["source"] == "a.png")["description"] == "already done"
259
+
260
+
261
+
262
+
263
+ def test_write_failure_is_not_marked_written(tmp_path, monkeypatch):
264
+ """Regression test: append_to_jsonl can fail silently (disk full,
265
+ permissions, transient network-share issue) -- describe_images() must
266
+ check its return value rather than assume the row landed, or the image
267
+ gets permanently marked processed with no row ever written and no retry
268
+ path short of --redo/--rebuild."""
269
+ img = _make_png(tmp_path / "a.png")
270
+
271
+ monkeypatch.setattr(
272
+ image_describer, "call_vision_llm",
273
+ lambda **kwargs: json.dumps({"description": "**Subject:** X"}),
274
+ )
275
+ monkeypatch.setattr(image_describer, "append_to_jsonl", lambda path, rows: False)
276
+
277
+ written = image_describer.describe_images(
278
+ new_image_paths=[img],
279
+ output_dir=str(tmp_path),
280
+ vision_provider="gpt",
281
+ vision_api_key="sk-test",
282
+ vision_model="gpt-5.4",
283
+ )
284
+
285
+ assert written == []
286
+
287
+
288
+ def test_describe_images_uses_its_own_ollama_timeout(tmp_path, monkeypatch):
289
+ """Regression test: this path's timeout must be decoupled from
290
+ figure_describer.py's context-window-scaled formula -- calibrated
291
+ separately (900s) against a real measured baseline, since
292
+ IMAGE_DESCRIPTION_PROMPT's verbatim-text-transcription requirement can
293
+ demand much more output than FIGURE_PROMPT ever needs."""
294
+ img = _make_png(tmp_path / "a.png")
295
+ captured = {}
296
+
297
+ def fake(**kwargs):
298
+ captured.update(kwargs)
299
+ return json.dumps({"description": "**Subject:** X"})
300
+
301
+ monkeypatch.setattr(image_describer, "call_vision_llm", fake)
302
+
303
+ image_describer.describe_images(
304
+ new_image_paths=[img],
305
+ output_dir=str(tmp_path),
306
+ vision_provider="ollama",
307
+ vision_api_key="",
308
+ vision_model="qwen2.5-vl:32b",
309
+ )
310
+
311
+ assert captured["ollama_timeout_seconds"] == image_describer._OLLAMA_IMAGE_TIMEOUT_SECONDS
312
+ assert image_describer._OLLAMA_IMAGE_TIMEOUT_SECONDS == 900.0
313
+
314
+
315
+ def test_no_new_images_returns_empty_list_without_calling_vision(tmp_path, monkeypatch):
316
+ def fail_if_called(**kwargs):
317
+ raise AssertionError("should not be called")
318
+
319
+ monkeypatch.setattr(image_describer, "call_vision_llm", fail_if_called)
320
+
321
+ written = image_describer.describe_images(
322
+ new_image_paths=[],
323
+ output_dir=str(tmp_path),
324
+ vision_provider="gpt",
325
+ vision_api_key="sk-test",
326
+ vision_model="gpt-5.4",
327
+ )
328
+ assert written == []
@@ -0,0 +1,79 @@
1
+ from visual_parser import image_tracker
2
+ from visual_parser.image_tracker import find_new_images
3
+
4
+
5
+ def test_finds_images_by_extension(tmp_path):
6
+ (tmp_path / "a.png").write_bytes(b"fake")
7
+ (tmp_path / "b.JPG").write_bytes(b"fake") # case-insensitive
8
+ (tmp_path / "c.pdf").write_bytes(b"fake") # not an image -- excluded
9
+ (tmp_path / "d.txt").write_bytes(b"fake") # not an image -- excluded
10
+
11
+ registry_path = str(tmp_path / "parsed_images.txt")
12
+ found = find_new_images(str(tmp_path), registry_path)
13
+
14
+ basenames = {__import__("os").path.basename(p) for p in found}
15
+ assert basenames == {"a.png", "b.JPG"}
16
+
17
+
18
+ def test_already_processed_images_are_excluded(tmp_path):
19
+ (tmp_path / "a.png").write_bytes(b"fake")
20
+ (tmp_path / "b.png").write_bytes(b"fake")
21
+
22
+ registry_path = tmp_path / "parsed_images.txt"
23
+ registry_path.write_text("a.png\n", encoding="utf-8")
24
+
25
+ found = find_new_images(str(tmp_path), str(registry_path))
26
+ assert [p for p in found if p.endswith("a.png")] == []
27
+ assert any(p.endswith("b.png") for p in found)
28
+
29
+
30
+ def test_rebuild_ignores_registry(tmp_path):
31
+ (tmp_path / "a.png").write_bytes(b"fake")
32
+
33
+ registry_path = tmp_path / "parsed_images.txt"
34
+ registry_path.write_text("a.png\n", encoding="utf-8")
35
+
36
+ found = find_new_images(str(tmp_path), str(registry_path), rebuild=True)
37
+ assert any(p.endswith("a.png") for p in found)
38
+
39
+
40
+ def test_no_images_returns_empty_list(tmp_path):
41
+ (tmp_path / "doc.pdf").write_bytes(b"fake")
42
+ found = find_new_images(str(tmp_path), str(tmp_path / "parsed_images.txt"))
43
+ assert found == []
44
+
45
+
46
+ def test_load_processed_pdfs_is_monkeypatchable_at_module_level(tmp_path, monkeypatch):
47
+ """Regression test: load_processed_pdfs must be imported at module level
48
+ (not inside find_new_images' own body), or monkeypatch.setattr(
49
+ image_tracker, "load_processed_pdfs", ...) -- the idiomatic way tests in
50
+ this file stub registry-loading without touching the filesystem --
51
+ would silently have no effect, since a function-local import re-resolves
52
+ the real pdf_tracker.load_processed_pdfs fresh on every call."""
53
+ (tmp_path / "a.png").write_bytes(b"fake")
54
+
55
+ monkeypatch.setattr(image_tracker, "load_processed_pdfs", lambda registry_path: ["a.png"])
56
+
57
+ found = find_new_images(str(tmp_path), str(tmp_path / "parsed_images.txt"))
58
+ assert found == [] # "a.png" reported processed by the stub, correctly excluded
59
+
60
+
61
+ def test_registry_path_is_explicit_not_joined_against_input_dir(tmp_path):
62
+ """Deliberate deviation from find_new_pdfs(): the registry lives wherever
63
+ the caller points it (e.g. output_dir), not auto-joined against input_dir
64
+ -- this sidesteps the pre-existing PDF-side input_dir/output_dir registry
65
+ mismatch bug."""
66
+ input_dir = tmp_path / "input"
67
+ output_dir = tmp_path / "output"
68
+ input_dir.mkdir()
69
+ output_dir.mkdir()
70
+ (input_dir / "a.png").write_bytes(b"fake")
71
+
72
+ registry_in_output = output_dir / "parsed_images.txt"
73
+ registry_in_output.write_text("a.png\n", encoding="utf-8")
74
+
75
+ # Registry says "a.png" is done, and it's correctly excluded even though
76
+ # the registry file lives in a completely different directory than the
77
+ # images themselves.
78
+ found = find_new_images(str(input_dir), str(registry_in_output))
79
+ assert found == []