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.
Files changed (48) hide show
  1. {visual_parser-2.2.0 → visual_parser-2.2.1}/PKG-INFO +1 -1
  2. {visual_parser-2.2.0 → visual_parser-2.2.1}/pyproject.toml +1 -1
  3. {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_cli_parity.py +24 -0
  4. {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_config.py +23 -0
  5. {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_figure_describer.py +27 -0
  6. visual_parser-2.2.1/tests/test_image_describer.py +328 -0
  7. visual_parser-2.2.1/tests/test_image_tracker.py +79 -0
  8. visual_parser-2.2.1/tests/test_jsonl_writer.py +61 -0
  9. visual_parser-2.2.1/tests/test_kb_redo.py +85 -0
  10. visual_parser-2.2.1/tests/test_metadata_extractor.py +99 -0
  11. visual_parser-2.2.1/tests/test_pdf_tracker.py +48 -0
  12. visual_parser-2.2.1/tests/test_pipeline.py +461 -0
  13. visual_parser-2.2.1/tests/test_prompts.py +24 -0
  14. {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_vision_llm.py +27 -0
  15. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/__init__.py +1 -1
  16. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/cli.py +41 -9
  17. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/cli_main.py +40 -9
  18. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/config.py +28 -2
  19. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/figure_describer.py +14 -1
  20. visual_parser-2.2.1/visual_parser/image_describer.py +329 -0
  21. visual_parser-2.2.1/visual_parser/image_tracker.py +71 -0
  22. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/jsonl_writer.py +64 -3
  23. visual_parser-2.2.1/visual_parser/kb_redo.py +148 -0
  24. visual_parser-2.2.1/visual_parser/metadata_extractor.py +168 -0
  25. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/pdf_tracker.py +25 -0
  26. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/pipeline.py +138 -24
  27. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/prompts.py +78 -0
  28. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/text_extractor.py +15 -28
  29. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/vision_llm.py +46 -22
  30. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/PKG-INFO +1 -1
  31. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/SOURCES.txt +10 -0
  32. visual_parser-2.2.0/tests/test_pipeline.py +0 -168
  33. visual_parser-2.2.0/visual_parser/metadata_extractor.py +0 -94
  34. {visual_parser-2.2.0 → visual_parser-2.2.1}/LICENSE +0 -0
  35. {visual_parser-2.2.0 → visual_parser-2.2.1}/README.md +0 -0
  36. {visual_parser-2.2.0 → visual_parser-2.2.1}/setup.cfg +0 -0
  37. {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_integration.py +0 -0
  38. {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_model_catalog.py +0 -0
  39. {visual_parser-2.2.0 → visual_parser-2.2.1}/tests/test_ollama_local.py +0 -0
  40. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/__main__.py +0 -0
  41. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/model_catalog.py +0 -0
  42. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/nougat_engine.py +0 -0
  43. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/ollama_local.py +0 -0
  44. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser/openai_gateway.py +0 -0
  45. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/dependency_links.txt +0 -0
  46. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/entry_points.txt +0 -0
  47. {visual_parser-2.2.0 → visual_parser-2.2.1}/visual_parser.egg-info/requires.txt +0 -0
  48. {visual_parser-2.2.0 → visual_parser-2.2.1}/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.1
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
@@ -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.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 == []