visual-parser 2.2.0__tar.gz → 2.2.1__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-2.2.1}/PKG-INFO +1 -1
- {visual_parser-2.2.0 → visual_parser-2.2.1}/pyproject.toml +1 -1
- {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_cli_parity.py +24 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_config.py +23 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_figure_describer.py +27 -0
- visual_parser-2.2.1/tests/test_image_describer.py +328 -0
- visual_parser-2.2.1/tests/test_image_tracker.py +79 -0
- visual_parser-2.2.1/tests/test_jsonl_writer.py +61 -0
- visual_parser-2.2.1/tests/test_kb_redo.py +85 -0
- visual_parser-2.2.1/tests/test_metadata_extractor.py +99 -0
- visual_parser-2.2.1/tests/test_pdf_tracker.py +48 -0
- visual_parser-2.2.1/tests/test_pipeline.py +461 -0
- visual_parser-2.2.1/tests/test_prompts.py +24 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_vision_llm.py +27 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/__init__.py +1 -1
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/cli.py +41 -9
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/cli_main.py +40 -9
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/config.py +28 -2
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/figure_describer.py +14 -1
- visual_parser-2.2.1/visual_parser/image_describer.py +329 -0
- visual_parser-2.2.1/visual_parser/image_tracker.py +71 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/jsonl_writer.py +64 -3
- visual_parser-2.2.1/visual_parser/kb_redo.py +148 -0
- visual_parser-2.2.1/visual_parser/metadata_extractor.py +168 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/pdf_tracker.py +25 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/pipeline.py +138 -24
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/prompts.py +78 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/text_extractor.py +15 -28
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/vision_llm.py +46 -22
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/PKG-INFO +1 -1
- {visual_parser-2.2.0 → visual_parser-2.2.1}/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.1}/LICENSE +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/README.md +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/setup.cfg +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_integration.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_model_catalog.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_ollama_local.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/__main__.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/model_catalog.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/nougat_engine.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/ollama_local.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/openai_gateway.py +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/dependency_links.txt +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/entry_points.txt +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/requires.txt +0 -0
- {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/top_level.txt +0 -0
|
@@ -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.1"
|
|
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,29 @@ 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
|
+
|
|
39
62
|
def test_vision_context_pages_defaults_to_zero(monkeypatch):
|
|
40
63
|
monkeypatch.delenv("VISUAL_PARSER_VISION_CONTEXT_PAGES", raising=False)
|
|
41
64
|
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 == []
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from visual_parser.jsonl_writer import append_to_jsonl, atomic_rewrite_jsonl
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_append_returns_true_on_success(tmp_path):
|
|
7
|
+
path = tmp_path / "out.jsonl"
|
|
8
|
+
assert append_to_jsonl(str(path), [{"a": 1}]) is True
|
|
9
|
+
assert json.loads(path.read_text(encoding="utf-8").strip()) == {"a": 1}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_append_returns_false_when_new_data_is_not_a_list(tmp_path):
|
|
13
|
+
path = tmp_path / "out.jsonl"
|
|
14
|
+
assert append_to_jsonl(str(path), {"a": 1}) is False # type: ignore[arg-type]
|
|
15
|
+
assert not path.exists()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_append_returns_false_on_filesystem_error(tmp_path, monkeypatch):
|
|
19
|
+
"""Simulates a disk-full/permission-style failure: open() itself raises."""
|
|
20
|
+
path = tmp_path / "out.jsonl"
|
|
21
|
+
|
|
22
|
+
def raising_open(*a, **k):
|
|
23
|
+
raise OSError("disk full")
|
|
24
|
+
|
|
25
|
+
monkeypatch.setattr("builtins.open", raising_open)
|
|
26
|
+
assert append_to_jsonl(str(path), [{"a": 1}]) is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# atomic_rewrite_jsonl
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def _write_lines(path, rows):
|
|
34
|
+
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_atomic_rewrite_keeps_and_drops_rows(tmp_path):
|
|
38
|
+
path = tmp_path / "kb.jsonl"
|
|
39
|
+
_write_lines(path, [{"source": "a.pdf"}, {"source": "b.pdf"}, {"source": "a.pdf"}])
|
|
40
|
+
|
|
41
|
+
stats = atomic_rewrite_jsonl(str(path), lambda row: row["source"] != "a.pdf")
|
|
42
|
+
|
|
43
|
+
assert stats == {"removed": 2, "kept": 1, "malformed": 0}
|
|
44
|
+
remaining = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
45
|
+
assert remaining == [{"source": "b.pdf"}]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_atomic_rewrite_counts_malformed_lines(tmp_path):
|
|
49
|
+
path = tmp_path / "kb.jsonl"
|
|
50
|
+
path.write_text('{"source": "a.pdf"}\nnot json\n{"source": "b.pdf"}\n', encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
stats = atomic_rewrite_jsonl(str(path), lambda row: True)
|
|
53
|
+
|
|
54
|
+
assert stats == {"removed": 0, "kept": 2, "malformed": 1}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_atomic_rewrite_missing_file_is_a_graceful_noop(tmp_path):
|
|
58
|
+
path = tmp_path / "does_not_exist.jsonl"
|
|
59
|
+
stats = atomic_rewrite_jsonl(str(path), lambda row: True)
|
|
60
|
+
assert stats == {"removed": 0, "kept": 0, "malformed": 0}
|
|
61
|
+
assert not path.exists() # not created by this call
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from visual_parser.kb_redo import KB_JSONL_FILENAMES, REGISTRY_FILENAMES, redo_entries
|
|
4
|
+
from visual_parser.pdf_tracker import load_processed_pdfs, mark_as_processed
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _write_rows(path, rows):
|
|
8
|
+
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_removes_only_matching_rows_across_all_kb_files(tmp_path):
|
|
12
|
+
for filename in KB_JSONL_FILENAMES:
|
|
13
|
+
_write_rows(
|
|
14
|
+
tmp_path / filename,
|
|
15
|
+
[{"source": "a.pdf", "x": 1}, {"source": "b.pdf", "x": 2}, {"source": "a.pdf", "x": 3}],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
result = redo_entries(str(tmp_path), ["a.pdf"])
|
|
19
|
+
|
|
20
|
+
assert result["totals"]["rows_removed"] == 2 * len(KB_JSONL_FILENAMES)
|
|
21
|
+
for filename in KB_JSONL_FILENAMES:
|
|
22
|
+
rows = [json.loads(l) for l in (tmp_path / filename).read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
23
|
+
assert rows == [{"source": "b.pdf", "x": 2}]
|
|
24
|
+
assert result["cleared"] == ["a.pdf"]
|
|
25
|
+
assert result["not_found"] == []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_backup_created_once_not_again_on_second_call(tmp_path):
|
|
29
|
+
path = tmp_path / "02_visuals_kb.jsonl"
|
|
30
|
+
_write_rows(path, [{"source": "a.pdf"}])
|
|
31
|
+
backup_path = tmp_path / "02_visuals_kb_backup_before_redo.jsonl"
|
|
32
|
+
|
|
33
|
+
redo_entries(str(tmp_path), ["a.pdf"])
|
|
34
|
+
assert backup_path.exists()
|
|
35
|
+
first_backup_content = backup_path.read_text(encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
# Re-seed the file and call again -- backup must NOT be overwritten.
|
|
38
|
+
_write_rows(path, [{"source": "c.pdf"}])
|
|
39
|
+
redo_entries(str(tmp_path), ["c.pdf"])
|
|
40
|
+
assert backup_path.read_text(encoding="utf-8") == first_backup_content
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_not_found_separates_typo_from_real_name(tmp_path):
|
|
44
|
+
_write_rows(tmp_path / "02_visuals_kb.jsonl", [{"source": "real.pdf"}])
|
|
45
|
+
|
|
46
|
+
result = redo_entries(str(tmp_path), ["real.pdf", "typo.pdf"])
|
|
47
|
+
|
|
48
|
+
assert result["cleared"] == ["real.pdf"]
|
|
49
|
+
assert result["not_found"] == ["typo.pdf"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_registries_get_entries_stripped(tmp_path):
|
|
53
|
+
for filename in REGISTRY_FILENAMES:
|
|
54
|
+
mark_as_processed(str(tmp_path / filename), ["a.pdf", "b.png"])
|
|
55
|
+
|
|
56
|
+
result = redo_entries(str(tmp_path), ["a.pdf"])
|
|
57
|
+
|
|
58
|
+
for filename in REGISTRY_FILENAMES:
|
|
59
|
+
assert load_processed_pdfs(str(tmp_path / filename)) == ["b.png"]
|
|
60
|
+
assert result["totals"]["registry_entries_removed"] == len(REGISTRY_FILENAMES)
|
|
61
|
+
assert "a.pdf" in result["cleared"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_missing_files_handled_gracefully(tmp_path):
|
|
65
|
+
"""No KB files, no registries at all -- must not crash."""
|
|
66
|
+
result = redo_entries(str(tmp_path), ["ghost.pdf"])
|
|
67
|
+
assert result["cleared"] == []
|
|
68
|
+
assert result["not_found"] == ["ghost.pdf"]
|
|
69
|
+
assert result["totals"] == {"rows_removed": 0, "registry_entries_removed": 0}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_matches_by_basename_path_stripped(tmp_path):
|
|
73
|
+
"""Both the requested name and the on-disk basename should be
|
|
74
|
+
path-stripped before comparison -- a full path passed to --redo should
|
|
75
|
+
still match a bare basename stored as 'source'."""
|
|
76
|
+
_write_rows(tmp_path / "02_visuals_kb.jsonl", [{"source": "a.pdf"}])
|
|
77
|
+
|
|
78
|
+
result = redo_entries(str(tmp_path), [r"C:\some\other\dir\a.pdf"])
|
|
79
|
+
|
|
80
|
+
assert result["cleared"] == ["a.pdf"]
|
|
81
|
+
rows = [
|
|
82
|
+
json.loads(l) for l in (tmp_path / "02_visuals_kb.jsonl").read_text(encoding="utf-8").splitlines()
|
|
83
|
+
if l.strip()
|
|
84
|
+
]
|
|
85
|
+
assert rows == []
|