Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

287 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Fetch top-6 retrieval hits for each query in train/eval/test splits.
Sends text queries in batches to the wiki-screenshot search API (defaults
to :30895 which serves dora-ls005-ckpt150). Saves hits + gold info as
JSONL per split. Does NOT fetch tile images — that's download_tiles.py.
Output per line:
{
"query": "...",
"answer": "...",
"gold_path_rel": "images/shard_583/.../chunk_0000_00.png",
"gold_suffix": "shard_583/.../chunk_0000_00.png",
"hits": [
{"path": "/opt/dlami/nvme/kiwix_tiles/shard_.../chunk_.png",
"score": 0.70, "url": "...", "article_id": 123}
],
"gold_in_top6_pos": 0 # or -1 if miss
}
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
def shard_suffix(p: str) -> str:
parts = p.split("/")
for i, x in enumerate(parts):
if x.startswith("shard_"):
return "/".join(parts[i:])
return p
def search_batch(
api_url: str, queries: list[str], n_docs: int, timeout: int = 300, retries: int = 5
) -> list[dict]:
payload = {"queries": [{"text": q} for q in queries], "n_docs": n_docs}
body = json.dumps(payload).encode()
last_err = None
for attempt in range(retries):
try:
req = urllib.request.Request(
api_url.rstrip("/") + "/search",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())["results"]
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = e
wait = 2**attempt
print(
f" search_batch attempt {attempt + 1}/{retries} failed: {e}; retry in {wait}s",
file=sys.stderr,
)
time.sleep(wait)
raise RuntimeError(f"search_batch failed after {retries}: {last_err}")
def process_split(
split_name: str,
jsonl_path: Path,
out_path: Path,
api_url: str,
batch_size: int,
n_docs: int,
) -> dict:
# Resume: count existing lines
existing = 0
if out_path.exists():
with open(out_path) as f:
for _ in f:
existing += 1
print(f" [{split_name}] resume: {existing} rows already saved")
examples = []
with open(jsonl_path) as f:
for line in f:
examples.append(json.loads(line))
total = len(examples)
print(f" [{split_name}] total={total}, skipping first {existing}")
examples = examples[existing:]
if not examples:
print(f" [{split_name}] already complete, skipping")
return _collect_stats(out_path)
t0 = time.time()
n_done = existing
gold_in_topk = {1: 0, 3: 0, 6: 0}
gold_miss = 0
# Re-scan existing for stats
if out_path.exists():
with open(out_path) as f:
for line in f:
r = json.loads(line)
pos = r.get("gold_in_top6_pos", -1)
if pos < 0:
gold_miss += 1
else:
for k in (1, 3, 6):
if pos < k:
gold_in_topk[k] += 1
with open(out_path, "a") as out_f:
for i in range(0, len(examples), batch_size):
batch = examples[i : i + batch_size]
queries = [ex["query"] for ex in batch]
try:
results = search_batch(api_url, queries, n_docs=n_docs)
except Exception as e:
print(f" [{split_name}] FATAL at batch {i}: {e}", file=sys.stderr)
raise
for ex, res in zip(batch, results):
gold_rel = ex["chunk_path"]
gs = shard_suffix(gold_rel)
hits = res.get("hits", [])
hit_sufs = [shard_suffix(h["path"]) for h in hits]
try:
pos = hit_sufs.index(gs)
except ValueError:
pos = -1
if pos < 0:
gold_miss += 1
else:
for k in (1, 3, 6):
if pos < k:
gold_in_topk[k] += 1
# Keep only fields we need per hit
trimmed = [
{
"path": h["path"],
"score": h.get("score"),
"article_id": h.get("article_id"),
"url": h.get("url"),
}
for h in hits
]
row = {
"query": ex["query"],
"answer": ex["answer"],
"gold_path_rel": gold_rel,
"gold_suffix": gs,
"hits": trimmed,
"gold_in_top6_pos": pos,
}
out_f.write(json.dumps(row, ensure_ascii=False) + "\n")
out_f.flush()
n_done += len(batch)
batch_idx = i // batch_size
if batch_idx % 5 == 0 or n_done == total:
el = time.time() - t0
rate = (n_done - existing) / max(el, 1e-9)
eta = (total - n_done) / max(rate, 1e-9) / 60
print(
f" [{split_name}] {n_done}/{total} "
f"({rate:.1f} q/s, eta {eta:.1f} min) "
f"gold@1={gold_in_topk[1] / max(1, n_done) * 100:.1f}% "
f"gold@3={gold_in_topk[3] / max(1, n_done) * 100:.1f}% "
f"gold@6={gold_in_topk[6] / max(1, n_done) * 100:.1f}% "
f"miss={gold_miss / max(1, n_done) * 100:.1f}%"
)
return {
"split": split_name,
"total": n_done,
"gold_in_top1": gold_in_topk[1],
"gold_in_top3": gold_in_topk[3],
"gold_in_top6": gold_in_topk[6],
"gold_miss": gold_miss,
}
def _collect_stats(path: Path) -> dict:
gold_in_topk = {1: 0, 3: 0, 6: 0}
gold_miss = 0
n = 0
if path.exists():
with open(path) as f:
for line in f:
r = json.loads(line)
n += 1
pos = r.get("gold_in_top6_pos", -1)
if pos < 0:
gold_miss += 1
else:
for k in (1, 3, 6):
if pos < k:
gold_in_topk[k] += 1
return {
"split": path.stem,
"total": n,
"gold_in_top1": gold_in_topk[1],
"gold_in_top3": gold_in_topk[3],
"gold_in_top6": gold_in_topk[6],
"gold_miss": gold_miss,
}
def main():
p = argparse.ArgumentParser()
p.add_argument(
"--dataset-dir",
default="/scratch/users/zwcolin/cxr_embeds/external_data/screenshot-training-natural-filtered-v2",
)
p.add_argument(
"--output-dir",
default="/scratch/users/zwcolin/cxr_embeds/sft_data/retrieval_raw",
)
p.add_argument("--api-url", default="http://localhost:30895")
p.add_argument("--batch-size", type=int, default=128)
p.add_argument("--n-docs", type=int, default=6)
p.add_argument(
"--splits",
nargs="+",
default=["test", "eval", "train"],
help="Splits to run; order = processing order",
)
args = p.parse_args()
dataset_dir = Path(args.dataset_dir)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
split_files = {
"train": "train_hn_with_answer.jsonl",
"eval": "eval_hn_with_answer.jsonl",
"test": "test_hn_with_answer.jsonl",
}
print(f"API: {args.api_url}")
print(f"Output: {output_dir}")
print(f"Splits: {args.splits}")
print(f"Batch: {args.batch_size}, n_docs={args.n_docs}")
print()
all_stats = []
for split in args.splits:
jsonl = dataset_dir / split_files[split]
out = output_dir / f"{split}.jsonl"
if not jsonl.exists():
print(f" SKIP {split}: {jsonl} missing")
continue
print(f"=== {split} ===")
stats = process_split(
split, jsonl, out, args.api_url, args.batch_size, args.n_docs
)
all_stats.append(stats)
summary = {
"api_url": args.api_url,
"n_docs": args.n_docs,
"batch_size": args.batch_size,
"splits": all_stats,
}
summary_path = output_dir / "summary.json"
with open(summary_path, "w") as f:
json.dump(summary, f, indent=2)
print(f"\nSummary: {summary_path}")
for s in all_stats:
n = max(1, s["total"])
print(
f" {s['split']:5s} n={s['total']} "
f"gold@1={s['gold_in_top1'] / n * 100:5.1f}% "
f"gold@3={s['gold_in_top3'] / n * 100:5.1f}% "
f"gold@6={s['gold_in_top6'] / n * 100:5.1f}% "
f"miss={s['gold_miss'] / n * 100:5.1f}%"
)
if __name__ == "__main__":
main()