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.
- {visual_parser-2.2.0/visual_parser.egg-info → visual_parser-2.2.2}/PKG-INFO +9 -5
- {visual_parser-2.2.0 → visual_parser-2.2.2}/README.md +8 -4
- {visual_parser-2.2.0 → visual_parser-2.2.2}/pyproject.toml +1 -1
- {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_cli_parity.py +24 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_config.py +37 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_figure_describer.py +27 -0
- visual_parser-2.2.2/tests/test_image_describer.py +328 -0
- visual_parser-2.2.2/tests/test_image_tracker.py +79 -0
- visual_parser-2.2.2/tests/test_jsonl_writer.py +61 -0
- visual_parser-2.2.2/tests/test_kb_redo.py +85 -0
- visual_parser-2.2.2/tests/test_metadata_extractor.py +99 -0
- visual_parser-2.2.2/tests/test_pdf_tracker.py +48 -0
- visual_parser-2.2.2/tests/test_pipeline.py +461 -0
- visual_parser-2.2.2/tests/test_prompts.py +24 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_vision_llm.py +27 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/__init__.py +1 -1
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/cli.py +51 -9
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/cli_main.py +50 -9
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/config.py +37 -2
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/figure_describer.py +14 -1
- visual_parser-2.2.2/visual_parser/image_describer.py +329 -0
- visual_parser-2.2.2/visual_parser/image_tracker.py +71 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/jsonl_writer.py +64 -3
- visual_parser-2.2.2/visual_parser/kb_redo.py +148 -0
- visual_parser-2.2.2/visual_parser/metadata_extractor.py +168 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/pdf_tracker.py +25 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/pipeline.py +139 -24
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/prompts.py +78 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/text_extractor.py +20 -29
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/vision_llm.py +46 -22
- {visual_parser-2.2.0 → visual_parser-2.2.2/visual_parser.egg-info}/PKG-INFO +9 -5
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/SOURCES.txt +10 -0
- visual_parser-2.2.0/tests/test_pipeline.py +0 -168
- visual_parser-2.2.0/visual_parser/metadata_extractor.py +0 -94
- {visual_parser-2.2.0 → visual_parser-2.2.2}/LICENSE +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/setup.cfg +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_integration.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_model_catalog.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/tests/test_ollama_local.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/__main__.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/model_catalog.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/nougat_engine.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/ollama_local.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser/openai_gateway.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/dependency_links.txt +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/entry_points.txt +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.2}/visual_parser.egg-info/requires.txt +0 -0
- {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.
|
|
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
|

|
|
51
51
|

|
|
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
|
|
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
|

|
|
6
6
|

|
|
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
|
|
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.
|
|
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 == []
|