Skip to content

Commit bfe11a3

Browse files
authored
fix: post-merge audit follow-ups (idle-wait default, incremental re-runs, eval config, progress, alpha) (#119)
* fix(index): make wait_network_idle default source-aware, not blanket PR #96 defaulted wait_network_idle=True for every index-build URL render. The SPA blank-tile problem it solves is real only for arbitrary external URLs (the `web` source). Applied uniformly, the default: - silently disabled the turbo (fast_cdp) capture path for ALL renders — cdp.py forces the standard path when wait_network_idle is set — roughly halving capture throughput on turbo-capable Chrome; - added a >=500ms (up to 12s) per-page idle floor to kiwix (localhost) and local file:// text renders, where assets are ready before `load` fires and the wait buys nothing (see the measurements referenced in docs/screenshot-throughput-optimization.md); - contradicted the still-present design record in cdp.py's docstring that the idle wait is opt-in for batch renders. Default it on only for `source.type == "web"`; an explicit `ingest: {wait_network_idle: ...}` still always wins. Update the cdp.py docstring to record the new split. Also copy the ingest config before mutating it, so pop/setdefault no longer leak into DEFAULT_CONFIG. * fix(index): detect stale tile dirs on incremental re-runs; harden manifest writes article_id is the source-enumeration position index, and every render path skipped an existing {idx}.png.tiles on bare existence. When the source set changes between runs (a file added/removed/renamed), positions shift: the skip reused another document's pixels and the stamp loop then overwrote its article_id with the new position — silently pairing one document's tiles with another document's metadata, while the shifted document was never rendered at all. This is the same misalignment class the manifest stamp was introduced to fix, resurfacing through the skip path. - _needs_render(): reuse a tile dir only if its manifest parses and records the same source document (`source`, falling back to legacy `url`) as the doc now occupying that position; on mismatch or corruption, warn, remove the stale dir, and re-render. All four render paths (URL/text/PDF/image) now go through it. Legacy text dirs (whose recorded url is a tempfile path) re-render once and are stamped with their true source thereafter. - Stamp loop: also record `source` (the doc's url or path) so future runs can verify identity; skip the rewrite when already stamped (no mtime churn); write via tmp file + os.replace so a crash mid-write can never truncate a manifest. - chunk.py: a corrupt tiles.json now skips that article with a warning instead of raising an uncaught JSONDecodeError that failed the whole build through subprocess check=True. Tests: tests/test_incremental_rerun.py exercises the real helpers (missing dir, match, legacy url match, shifted position, corruption at render and chunk stages). The positional identity itself remains; making identity content/path-stable (articles.json as an id-keyed map) is a follow-up design change. * fix(eval): let the model registry's api_base/api_key actually take effect --api-base defaulted to "http://localhost:8000/v1" and --api-key to "dummy" — both truthy — so the `args.api_base if args.api_base else model_config[...]` resolution NEVER fell through to the model registry. The MiniMax entries added in #113 (and any registry endpoint) were dead on arrival via the documented `run_bench.py --model MiniMax-M3` flow: requests always went to localhost:8000 with key "dummy". Default both flags to None so explicit flags still win, the registry (including MINIMAX_API_BASE / MINIMAX_API_KEY env overrides) is used otherwise, and the old localhost/dummy values remain as final fallbacks for models with no registry endpoint. The --open-router / commonstack branches already treat "dummy" as unset, so None passes them unchanged. * fix(embed): keep chunk progress bar out of pool workers; restore loggable progress The Chunking tqdm bar added in #108 ran inside process_shard, which --tiles-dir mode fans out to a ProcessPoolExecutor with up to 96 workers — that many concurrent bars share one stderr and trample each other. Give process_shard a progress flag: the bar stays for the single-shard path (what `pixelrag index build` invokes), pool workers disable it, and the parent shows one shard-level bar over as_completed instead. Also replace the nonsensical `disable=not all_article_dirs` guard. #108 also removed the periodic "Embedded X/Y" log record because it broke the tqdm display — but that record was the only progress that reached log files in non-interactive runs (nohup/CI/redirected stderr). Restore it at 1/100 cadence, emitted only when stderr is not a TTY, so interactive runs keep a clean bar and unattended runs stay observable after the fact. * fix(index): composite transparent local images onto white, not black The local-image render path (#95) saved tiles as JPEG via a bare convert("RGB"), which maps fully-transparent pixels to their underlying RGB values — black for typical chart/logo/diagram exports. Dark-on- transparent images (the standard matplotlib / design-tool export, and issue #67's own chart.png example) became dark-on-black garbage: embedded as noise, unretrievable, unreadable when served. Composite anything with an alpha channel (RGBA/LA/PA, or palette images carrying transparency) onto a white background before dropping to RGB; opaque images keep the direct convert.
1 parent cbb850d commit bfe11a3

6 files changed

Lines changed: 212 additions & 43 deletions

File tree

embed/src/pixelrag_embed/chunk.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,13 @@ def chunk_article(article_dir: str, dry_run: bool = False, force: bool = False)
8585
raw = f.read().strip()
8686
if not raw:
8787
return None
88-
meta = json.loads(raw)
88+
try:
89+
meta = json.loads(raw)
90+
except json.JSONDecodeError:
91+
# A truncated manifest (crash mid-write) must not take down the whole
92+
# shard/build — skip this article like other unreadable dirs.
93+
logger.warning("Corrupt tiles.json in %s — skipping", article_dir)
94+
return None
8995

9096
tile_names = meta.get("tiles", [])
9197
if not tile_names:
@@ -287,6 +293,7 @@ def process_shard(
287293
dry_run: bool = False,
288294
force: bool = False,
289295
delete_tiles: bool = False,
296+
progress: bool = True,
290297
) -> dict:
291298
"""Chunk all articles in a shard directory."""
292299
t0 = time.time()
@@ -315,9 +322,10 @@ def process_shard(
315322
if article_dir.is_dir() and article_dir.name.endswith(".png.tiles")
316323
]
317324

318-
for article_dir in tqdm(
319-
all_article_dirs, desc="Chunking", disable=not all_article_dirs
320-
):
325+
# The bar is disabled when this runs inside a ProcessPoolExecutor worker
326+
# (--tiles-dir mode): up to 96 concurrent bars would trample each other
327+
# on one terminal. The parent shows a shard-level bar instead.
328+
for article_dir in tqdm(all_article_dirs, desc="Chunking", disable=not progress):
321329
total_articles += 1
322330

323331
result = chunk_article(str(article_dir), dry_run=dry_run, force=force)
@@ -423,11 +431,18 @@ def main():
423431
with ProcessPoolExecutor(max_workers=args.workers) as pool:
424432
futures = {
425433
pool.submit(
426-
process_shard, sd, args.dry_run, args.force, args.delete_tiles
434+
process_shard,
435+
sd,
436+
args.dry_run,
437+
args.force,
438+
args.delete_tiles,
439+
progress=False,
427440
): sd
428441
for sd in shard_dirs
429442
}
430-
for fut in as_completed(futures):
443+
for fut in tqdm(
444+
as_completed(futures), total=len(futures), desc="Chunking shards"
445+
):
431446
sd = futures[fut]
432447
try:
433448
r = fut.result()

embed/src/pixelrag_embed/embed_cpu.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import json
2020
import logging
2121
import os
22+
import sys
2223
from pathlib import Path
2324

2425
import numpy as np
@@ -203,6 +204,12 @@ def embed_items(
203204
pooled = pooled / pooled.norm()
204205
embeddings[i] = pooled.cpu().numpy().astype(np.float16)
205206

207+
# tqdm covers interactive runs but never reaches log files; when
208+
# stderr is not a TTY (nohup/CI/redirects) emit a periodic record so
209+
# long embeds stay observable after the fact.
210+
if (i + 1) % 100 == 0 and not sys.stderr.isatty():
211+
logger.info("Embedded %d/%d", i + 1, len(items))
212+
206213
return embeddings
207214

208215

eval/run_bench.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -983,7 +983,7 @@ async def run_async(args):
983983
if args.api_base
984984
else (model_config["api_base"] or "http://localhost:8000/v1")
985985
)
986-
api_key = args.api_key if args.api_key else model_config["api_key"]
986+
api_key = args.api_key if args.api_key else (model_config["api_key"] or "dummy")
987987
model = model_config["model"]
988988

989989
# Generate output filename with model name if output is not explicitly set
@@ -1299,9 +1299,18 @@ def main():
12991299

13001300
# API args
13011301
parser.add_argument(
1302-
"--api-base", type=str, default="http://localhost:8000/v1", help="API base URL"
1302+
"--api-base",
1303+
type=str,
1304+
default=None,
1305+
help="API base URL (default: the model config's endpoint, "
1306+
"else http://localhost:8000/v1)",
1307+
)
1308+
parser.add_argument(
1309+
"--api-key",
1310+
type=str,
1311+
default=None,
1312+
help="API key (default: the model config's key, else 'dummy')",
13031313
)
1304-
parser.add_argument("--api-key", type=str, default="dummy", help="API key")
13051314
parser.add_argument(
13061315
"--open-router",
13071316
action="store_true",

index/src/pixelrag_index/pipelines.py

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""End-to-end pipeline: source -> ingest -> chunk -> embed -> build."""
22

33
import argparse
4+
import json
45
import logging
6+
import os
7+
import shutil
58
import subprocess
69
import sys
710
from pathlib import Path
@@ -11,6 +14,45 @@
1114
logger = logging.getLogger("pixelrag-index")
1215

1316

17+
def _needs_render(tiles_dir: Path, idx: int, doc) -> bool:
18+
"""True if {idx}.png.tiles must be (re)rendered for doc.
19+
20+
An existing tile directory is reusable only if its manifest parses and
21+
records the same source document (``source``, falling back to the legacy
22+
``url`` field) as the doc that now occupies position ``idx``. Position
23+
indices shift whenever the source set changes between runs (a file added,
24+
removed, or renamed) — reusing the directory then would silently pair one
25+
document's pixels with another document's metadata. On mismatch the stale
26+
directory is removed and re-rendered; corrupt manifests (e.g. a crash
27+
mid-write) are likewise re-rendered instead of poisoning later stages.
28+
"""
29+
tile_dir = tiles_dir / f"{idx}.png.tiles"
30+
manifest_path = tile_dir / "tiles.json"
31+
if not manifest_path.exists():
32+
return True
33+
expected = doc.url or doc.path
34+
try:
35+
manifest = json.loads(manifest_path.read_text())
36+
recorded = manifest.get("source") or manifest.get("url")
37+
except (json.JSONDecodeError, OSError):
38+
logger.warning(" Corrupt manifest in %s — re-rendering", tile_dir.name)
39+
recorded = None
40+
if recorded == expected:
41+
return False
42+
if recorded is not None:
43+
logger.warning(
44+
" %s was rendered from %r but position %d now holds %r — "
45+
"re-rendering (source set changed between runs; use --force "
46+
"for a clean rebuild)",
47+
tile_dir.name,
48+
recorded,
49+
idx,
50+
expected,
51+
)
52+
shutil.rmtree(tile_dir, ignore_errors=True)
53+
return True
54+
55+
1456
def _department_of(article: dict, source_root: str) -> str:
1557
"""Department = first sub-directory under the source root holding the file.
1658
@@ -49,17 +91,23 @@ def build(config: dict, limit: int | None = None, force: bool = False) -> Path:
4991
output = Path(config.get("output", "./index"))
5092
tiles_dir = output / "tiles"
5193
embeddings_dir = output / "embeddings"
52-
ingest_cfg = config.get("ingest", {})
53-
# Default to waiting for network idle — most modern pages are JS-rendered
54-
# SPAs that produce blank/incomplete tiles without this. Users can opt out
55-
# with `ingest: {wait_network_idle: false}` in their pixelrag.yaml.
56-
ingest_cfg.setdefault("wait_network_idle", True)
94+
# Copy so the pop/setdefault below never mutate the caller's dict (or the
95+
# module-level DEFAULT_CONFIG when the yaml has no `ingest:` section).
96+
ingest_cfg = dict(config.get("ingest", {}))
97+
# Wait for network idle by default only for the `web` source (arbitrary
98+
# external URLs), where JS-rendered SPAs fetch content after `load` and
99+
# would otherwise produce blank/incomplete tiles. Everything else — kiwix
100+
# (localhost), local text docs (file://) — has its assets ready before
101+
# `load` fires (see docs/screenshot-throughput-optimization.md), and the
102+
# idle wait would cost >=500ms per page AND disqualify the turbo capture
103+
# path (cdp.py forces the standard path when wait_network_idle is set).
104+
# An explicit `ingest: {wait_network_idle: ...}` in pixelrag.yaml wins.
105+
if config.get("source", {}).get("type") == "web":
106+
ingest_cfg.setdefault("wait_network_idle", True)
57107
embed_cfg = config.get("embed", {})
58108
device = embed_cfg.get("device", "cpu")
59109

60110
if force:
61-
import shutil
62-
63111
for d in (tiles_dir, embeddings_dir):
64112
if d.exists():
65113
shutil.rmtree(d)
@@ -68,7 +116,6 @@ def build(config: dict, limit: int | None = None, force: bool = False) -> Path:
68116

69117
# Stage 1: Render documents to tiles
70118
# Use sequential integer IDs as tile directory names so embed/serve can map them
71-
import json
72119
from pixelrag_render.render import render_urls, render_pdf
73120

74121
logger.info("Stage 1/4: Rendering %d documents to tiles...", len(docs))
@@ -102,9 +149,7 @@ def build(config: dict, limit: int | None = None, force: bool = False) -> Path:
102149
# Render URL batch — skip already-captured articles
103150
if url_docs:
104151
new_url_docs = [
105-
(idx, d)
106-
for idx, d in url_docs
107-
if not (tiles_dir / f"{idx}.png.tiles" / "tiles.json").exists()
152+
(idx, d) for idx, d in url_docs if _needs_render(tiles_dir, idx, d)
108153
]
109154
if new_url_docs:
110155
urls = [d.url for _, d in new_url_docs]
@@ -164,7 +209,7 @@ def _repl(m: re.Match) -> str:
164209

165210
with tempfile.TemporaryDirectory(prefix="pixelrag_text_") as tmp_dir:
166211
for idx, doc in text_docs:
167-
if (tiles_dir / f"{idx}.png.tiles" / "tiles.json").exists():
212+
if not _needs_render(tiles_dir, idx, doc):
168213
continue
169214
src_path = Path(doc.path)
170215
content = src_path.read_text(errors="replace")
@@ -196,9 +241,8 @@ def _repl(m: re.Match) -> str:
196241
# Render PDFs — use idx as tile directory name (like URLs) so directory
197242
# names are always the numeric article_id.
198243
for idx, doc in pdf_docs:
199-
out_dir = tiles_dir / f"{idx}.png.tiles"
200-
if (out_dir / "tiles.json").exists():
201-
continue # already rendered on a previous run
244+
if not _needs_render(tiles_dir, idx, doc):
245+
continue # already rendered for this same document
202246
try:
203247
render_pdf(doc.path, str(tiles_dir), stem=str(idx))
204248
except Exception as e:
@@ -213,12 +257,25 @@ def _repl(m: re.Match) -> str:
213257
_MAX_WIDTH = 4000 # cap large images to avoid VRAM pressure during embedding
214258

215259
for idx, doc in image_docs:
216-
tile_dir = tiles_dir / f"{idx}.png.tiles"
217-
if (tile_dir / "tiles.json").exists():
260+
if not _needs_render(tiles_dir, idx, doc):
218261
continue
262+
tile_dir = tiles_dir / f"{idx}.png.tiles"
219263
tile_dir.mkdir(parents=True, exist_ok=True)
220264
try:
221-
img = PILImage.open(doc.path).convert("RGB")
265+
img = PILImage.open(doc.path)
266+
# JPEG has no alpha: composite transparent images onto white
267+
# before dropping the channel. A bare convert("RGB") maps
268+
# fully-transparent pixels to their underlying RGB — black for
269+
# typical chart/logo exports — turning them into dark garbage.
270+
if img.mode in ("RGBA", "LA", "PA") or (
271+
img.mode == "P" and "transparency" in img.info
272+
):
273+
rgba = img.convert("RGBA")
274+
background = PILImage.new("RGB", rgba.size, "white")
275+
background.paste(rgba, mask=rgba.getchannel("A"))
276+
img = background
277+
else:
278+
img = img.convert("RGB")
222279
# Resize if too wide
223280
if img.width > _MAX_WIDTH:
224281
ratio = _MAX_WIDTH / img.width
@@ -240,22 +297,34 @@ def _repl(m: re.Match) -> str:
240297
logger.warning(" FAILED image %s: %s", doc.id, e)
241298
logger.info(" Rendered %d local images", len(image_docs))
242299

243-
# Write article_id into each tile directory's manifests so the embed
244-
# pipeline reads it explicitly instead of guessing from the directory name.
245-
# tiles.json always exists here; chunks.json exists only for PDFs (pdf.py
246-
# writes it at render time, and chunk.py then skips those dirs). For every
247-
# other source chunks.json is created by Stage 2's chunk.py, which
248-
# propagates article_id from tiles.json. So write whichever exist now.
249-
for idx, _ in url_docs + text_docs + pdf_docs + image_docs:
300+
# Write article_id and the source identity into each tile directory's
301+
# manifests, so the embed pipeline reads the id explicitly instead of
302+
# guessing from the directory name, and so the next run's _needs_render
303+
# can detect stale directories after the source set changes. tiles.json
304+
# always exists here; chunks.json exists only for PDFs (pdf.py writes it
305+
# at render time, and chunk.py then skips those dirs). For every other
306+
# source chunks.json is created by Stage 2's chunk.py, which propagates
307+
# article_id from tiles.json. So write whichever exist now.
308+
for idx, doc in url_docs + text_docs + pdf_docs + image_docs:
309+
identity = doc.url or doc.path
250310
for manifest_name in ("tiles.json", "chunks.json"):
251311
manifest_path = tiles_dir / f"{idx}.png.tiles" / manifest_name
252-
if manifest_path.exists():
253-
try:
254-
manifest = json.loads(manifest_path.read_text())
255-
manifest["article_id"] = idx
256-
manifest_path.write_text(json.dumps(manifest))
257-
except (json.JSONDecodeError, OSError):
258-
pass
312+
if not manifest_path.exists():
313+
continue
314+
try:
315+
manifest = json.loads(manifest_path.read_text())
316+
except (json.JSONDecodeError, OSError):
317+
logger.warning(" Could not stamp unreadable %s", manifest_path)
318+
continue
319+
if manifest.get("article_id") == idx and manifest.get("source") == identity:
320+
continue # already stamped — skip the rewrite
321+
manifest["article_id"] = idx
322+
manifest["source"] = identity
323+
# Atomic replace: a crash mid-write must not truncate the manifest
324+
# (a corrupt tiles.json would otherwise poison every later stage).
325+
tmp_path = manifest_path.with_name(manifest_name + ".tmp")
326+
tmp_path.write_text(json.dumps(manifest))
327+
os.replace(tmp_path, manifest_path)
259328

260329
# Save articles.json for serve API — title + URL per article.
261330
# Use the pipeline's sequential *position index* (0, 1, 2, …) rather than

render/src/pixelrag_render/backends/cdp.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,11 @@ def _readiness_expr(wait_network_idle: bool) -> str:
199199
200200
When ``wait_network_idle`` is set, also waits (after load) until no new
201201
resource has been fetched for ``NET_QUIET_MS`` — for SPAs that fetch their
202-
content *after* load. This costs a quiet window per page, so it is opt-in
203-
(the pixelbrowse skill / single-page renders), not the batch default.
202+
content *after* load. This costs a quiet window (>= NET_QUIET_MS, up to
203+
LOAD_TIMEOUT_MS) per page and disqualifies the turbo capture path, so it
204+
is off by default here; the pixelbrowse skill and the index pipeline's
205+
`web` source (arbitrary external URLs) enable it, while kiwix/localhost
206+
and file:// batch renders stay on the fast path.
204207
205208
Returns an async-IIFE expression resolving to the page height to tile.
206209
"""

tests/test_incremental_rerun.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Incremental re-run safety: stale tile dirs must be re-rendered, not relabeled.
2+
3+
Position indices are assigned by source enumeration order, so they shift when
4+
the source set changes between runs (a file added, removed, or renamed).
5+
_needs_render must detect that an existing {idx}.png.tiles directory was
6+
rendered from a *different* document and re-render it instead of letting the
7+
stamp loop silently pair one document's pixels with another's metadata.
8+
"""
9+
10+
import json
11+
12+
from pixelrag_index.pipelines import _needs_render
13+
from pixelrag_index.sources.base import Document
14+
15+
16+
def _make_tile_dir(tiles_dir, idx, manifest):
17+
tile_dir = tiles_dir / f"{idx}.png.tiles"
18+
tile_dir.mkdir(parents=True)
19+
(tile_dir / "tiles.json").write_text(json.dumps(manifest))
20+
return tile_dir
21+
22+
23+
def test_missing_dir_needs_render(tmp_path):
24+
doc = Document(id="a", path="/src/a.md")
25+
assert _needs_render(tmp_path, 0, doc) is True
26+
27+
28+
def test_matching_source_is_reused(tmp_path):
29+
_make_tile_dir(tmp_path, 0, {"source": "/src/a.md", "tiles": ["tile_0000.png"]})
30+
doc = Document(id="a", path="/src/a.md")
31+
assert _needs_render(tmp_path, 0, doc) is False
32+
33+
34+
def test_legacy_url_field_is_reused(tmp_path):
35+
# Dirs stamped before the `source` field existed recorded the render URL.
36+
_make_tile_dir(tmp_path, 0, {"url": "https://example.com/x", "tiles": []})
37+
doc = Document(id="x", url="https://example.com/x")
38+
assert _needs_render(tmp_path, 0, doc) is False
39+
40+
41+
def test_shifted_position_forces_rerender_and_removes_stale_dir(tmp_path):
42+
# Built over [a, c] -> dir 1 holds c. User adds b: position 1 is now b.
43+
stale = _make_tile_dir(tmp_path, 1, {"source": "/src/c.md", "tiles": []})
44+
doc_b = Document(id="b", path="/src/b.md")
45+
assert _needs_render(tmp_path, 1, doc_b) is True
46+
assert not stale.exists(), "stale dir must be removed before re-render"
47+
48+
49+
def test_corrupt_manifest_forces_rerender(tmp_path):
50+
tile_dir = tmp_path / "0.png.tiles"
51+
tile_dir.mkdir()
52+
(tile_dir / "tiles.json").write_text('{"source": "/src/a.md", "til') # truncated
53+
doc = Document(id="a", path="/src/a.md")
54+
assert _needs_render(tmp_path, 0, doc) is True
55+
assert not tile_dir.exists()
56+
57+
58+
def test_corrupt_manifest_skips_article_in_chunk_stage(tmp_path):
59+
# A truncated tiles.json must not crash the chunk stage (it used to raise
60+
# an uncaught JSONDecodeError and fail the whole build via check=True).
61+
from pixelrag_embed.chunk import chunk_article
62+
63+
article_dir = tmp_path / "0.png.tiles"
64+
article_dir.mkdir()
65+
(article_dir / "tiles.json").write_text('{"tiles": ["tile_0000.png"') # truncated
66+
assert chunk_article(str(article_dir)) is None

0 commit comments

Comments
 (0)