Files
startrail-org--pixelrag/train/clean_queries_simpleqa_style.py
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

680 lines
21 KiB
Python

#!/usr/bin/env python3
"""Clean query-only training rows toward a SimpleQA-like style.
This script reads one or more JSONL files that contain `query` fields and uses
Gemini to judge each query on two axes:
1. Naturalness: does it sound like a real user question?
2. SimpleQA style fit: does it resemble the style of SimpleQA factoid prompts?
The model only sees the query text. It does not inspect images or answers.
Outputs:
- cleaned JSONL with original rows preserved
- review JSONL with model scores / keep decisions
- summary JSON with before/after stats and token usage
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import random
import re
import threading
import time
from collections import Counter
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from pathlib import Path
DEFAULT_INPUT_GLOB = (
"training/data/lite-query-v2-full-filtered-hn-v2-chunks/chunk_*/filtered_hn.jsonl"
)
DEFAULT_SIMPLEQA_PATH = (
"/home/user/wiki-screenshot/eval/simpleqa_query_image_pairs.json"
)
MODEL_PRICING = {
"gemini-2.0-flash-001": {"input_per_m": 0.10, "output_per_m": 0.40},
"gemini-2.0-flash": {"input_per_m": 0.10, "output_per_m": 0.40},
"gemini-3.1-flash": {"input_per_m": 0.25, "output_per_m": 1.50},
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument(
"--input-glob",
default=DEFAULT_INPUT_GLOB,
help="Glob pattern for input JSONL files.",
)
parser.add_argument(
"--output",
required=True,
help="Output JSONL path for cleaned rows.",
)
parser.add_argument(
"--reviews-output",
default=None,
help="Optional JSONL path for model review decisions.",
)
parser.add_argument(
"--summary-output",
default=None,
help="Optional JSON path for summary stats.",
)
parser.add_argument(
"--simpleqa-path",
default=DEFAULT_SIMPLEQA_PATH,
help="Path to SimpleQA pair JSON used for style references.",
)
parser.add_argument(
"--target-count",
type=int,
default=50000,
help="Approximate number of rows to keep.",
)
parser.add_argument(
"--batch-size",
type=int,
default=20,
help="Queries per Gemini request.",
)
parser.add_argument(
"--concurrency",
type=int,
default=8,
help="Concurrent Gemini requests.",
)
parser.add_argument(
"--few-shot-count",
type=int,
default=12,
help="Number of SimpleQA examples shown in the prompt.",
)
parser.add_argument(
"--model",
default="gemini-2.0-flash-001",
help="Gemini model name. Use an accessible model in your project.",
)
parser.add_argument("--gemini-project", default="wise-coyote-478119-h0")
parser.add_argument("--gemini-location", default="global")
parser.add_argument("--max-retries", type=int, default=5)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument(
"--min-naturalness",
type=int,
default=4,
help="Minimum Gemini naturalness score for direct keep.",
)
parser.add_argument(
"--min-style-fit",
type=int,
default=4,
help="Minimum Gemini SimpleQA style score for direct keep.",
)
parser.add_argument(
"--dedupe-query",
action="store_true",
help="Keep only the highest-scoring row per normalized query.",
)
parser.add_argument(
"--resume",
action="store_true",
help="Reuse existing review file and skip already-scored row ids.",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="Optional cap on total input rows for smoke tests.",
)
return parser.parse_args()
def init_usage() -> dict:
return {
"prompt_tokens": 0,
"completion_tokens": 0,
"calls": 0,
}
def build_client(args: argparse.Namespace) -> dict:
os.environ.setdefault("GOOGLE_CLOUD_PROJECT", args.gemini_project)
os.environ.setdefault("GOOGLE_GENAI_USE_VERTEXAI", "true")
os.environ.setdefault("GOOGLE_CLOUD_LOCATION", args.gemini_location)
from google import genai
from google.genai.types import HttpOptions
client = genai.Client(http_options=HttpOptions(api_version="v1"))
return {
"client": client,
"usage": init_usage(),
"usage_lock": threading.Lock(),
}
def update_usage(
client_ctx: dict, prompt_tokens: int = 0, completion_tokens: int = 0
) -> None:
with client_ctx["usage_lock"]:
client_ctx["usage"]["prompt_tokens"] += int(prompt_tokens or 0)
client_ctx["usage"]["completion_tokens"] += int(completion_tokens or 0)
client_ctx["usage"]["calls"] += 1
def parse_json_from_text(text: str):
text = text.strip()
if not text:
raise ValueError("empty response")
try:
return json.loads(text)
except json.JSONDecodeError:
pass
match = re.search(r"(\[[\s\S]*\]|\{[\s\S]*\})", text)
if not match:
raise ValueError("no JSON object or array found")
return json.loads(match.group(1))
def call_gemini_json(client_ctx: dict, model: str, prompt: str, max_retries: int):
from google.genai.types import GenerateContentConfig
config = GenerateContentConfig(
temperature=0,
max_output_tokens=8192,
response_mime_type="application/json",
)
for attempt in range(1, max_retries + 1):
try:
resp = client_ctx["client"].models.generate_content(
model=model,
contents=prompt,
config=config,
)
usage = getattr(resp, "usage_metadata", None)
if usage is not None:
update_usage(
client_ctx,
prompt_tokens=getattr(usage, "prompt_token_count", 0),
completion_tokens=getattr(usage, "candidates_token_count", 0),
)
text = getattr(resp, "text", "") or ""
return parse_json_from_text(text)
except Exception:
if attempt == max_retries:
raise
time.sleep(min(2**attempt, 20))
raise RuntimeError("unreachable")
def iter_rows(path: Path):
with path.open() as f:
for line_no, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
yield line_no, json.loads(line)
def load_input_rows(args: argparse.Namespace) -> list[dict]:
rows = []
matched = [Path(path) for path in sorted(glob.glob(args.input_glob))]
if not matched:
raise FileNotFoundError(f"No files matched --input-glob={args.input_glob}")
row_id = 0
for path in matched:
for line_no, payload in iter_rows(path):
query = payload.get("query") or payload.get("question")
if not isinstance(query, str) or not query.strip():
continue
rows.append(
{
"row_id": row_id,
"source_file": str(path),
"source_line": line_no,
"query": query.strip(),
"payload": payload,
}
)
row_id += 1
if args.limit > 0 and len(rows) >= args.limit:
return rows
return rows
def normalize_query(query: str) -> str:
return " ".join(query.lower().split())
def question_start_bucket(query: str) -> str:
q = query.strip().lower()
for prefix in [
"what",
"who",
"which",
"when",
"where",
"why",
"how",
"in which",
"in what",
"on what",
"what is",
"what was",
]:
if q.startswith(prefix):
return prefix
parts = re.findall(r"[a-z0-9']+", q)
return parts[0] if parts else "<empty>"
def load_simpleqa_references(path: Path, few_shot_count: int, seed: int) -> list[dict]:
with path.open() as f:
data = json.load(f)
by_bucket = {}
for item in data:
question = item.get("question")
answer = item.get("answer")
if not isinstance(question, str) or not question.strip():
continue
bucket = question_start_bucket(question)
by_bucket.setdefault(bucket, []).append(
{
"question": question.strip(),
"answer": (answer or "").strip(),
"topic": item.get("topic") or "Unknown",
}
)
rng = random.Random(seed)
for values in by_bucket.values():
rng.shuffle(values)
ordered_buckets = sorted(by_bucket, key=lambda key: (-len(by_bucket[key]), key))
refs = []
while len(refs) < few_shot_count and ordered_buckets:
next_round = []
for bucket in ordered_buckets:
values = by_bucket[bucket]
if values:
refs.append(values.pop())
if values:
next_round.append(bucket)
if len(refs) >= few_shot_count:
break
ordered_buckets = next_round
return refs
def build_prompt(reference_examples: list[dict], batch: list[dict]) -> str:
ref_lines = []
for idx, ref in enumerate(reference_examples, 1):
ref_lines.append(
f"{idx}. topic={ref['topic']}\n"
f" question={json.dumps(ref['question'], ensure_ascii=False)}\n"
f" answer={json.dumps(ref['answer'], ensure_ascii=False)}"
)
candidate_json = json.dumps(
[{"id": row["row_id"], "query": row["query"]} for row in batch],
ensure_ascii=False,
indent=2,
)
return f"""You are cleaning a synthetic screenshot-retrieval training set.
Goal: keep only queries that read naturally and resemble SimpleQA-style factoid questions.
Judge ONLY the query text. Do not assume access to images, answers, or metadata.
High-quality queries usually:
- sound like something a real user would ask
- are single-hop factoid questions with short answers
- include natural disambiguating context when useful (time, role, location, quoted title, relation)
- feel similar to the reference questions below
Reject queries that are:
- awkward, templatic, or annotation-like
- unnaturally phrased, especially stiff starts like "In what..." or "At which..." when a plain wording would be more natural
- keywordy, malformed, or obviously synthetic
- broad explanatory prompts, opinion questions, yes/no questions, or multi-hop questions
Reference SimpleQA-style examples:
{chr(10).join(ref_lines)}
Now score these candidate queries.
Return ONLY a JSON array. One object per candidate with exactly these keys:
- id: integer
- naturalness: integer 1-5
- simpleqa_style_fit: integer 1-5
- keep: boolean
- reason: short string (<=12 words)
Scoring guide:
- 5 = excellent
- 4 = good / clearly keepable
- 3 = borderline
- 2 = weak
- 1 = poor
Candidates:
{candidate_json}
"""
def sanitize_decision(raw: dict, row_id: int) -> dict:
naturalness = raw.get("naturalness", 0)
style_fit = raw.get("simpleqa_style_fit", 0)
keep = raw.get("keep", False)
reason = raw.get("reason", "")
try:
naturalness = int(naturalness)
except Exception:
naturalness = 0
try:
style_fit = int(style_fit)
except Exception:
style_fit = 0
if isinstance(keep, str):
keep = keep.strip().lower() in {"true", "yes", "1", "keep"}
return {
"id": int(row_id),
"naturalness": max(0, min(5, naturalness)),
"simpleqa_style_fit": max(0, min(5, style_fit)),
"keep": bool(keep),
"reason": str(reason).strip()[:200],
}
def score_batch(
client_ctx: dict,
args: argparse.Namespace,
references: list[dict],
batch: list[dict],
) -> list[dict]:
prompt = build_prompt(references, batch)
parsed = call_gemini_json(client_ctx, args.model, prompt, args.max_retries)
if not isinstance(parsed, list):
raise ValueError("Gemini response is not a JSON array")
by_id = {}
for item in parsed:
if not isinstance(item, dict) or "id" not in item:
continue
by_id[int(item["id"])] = sanitize_decision(item, item["id"])
decisions = []
for row in batch:
decision = by_id.get(row["row_id"])
if decision is None:
decision = {
"id": row["row_id"],
"naturalness": 0,
"simpleqa_style_fit": 0,
"keep": False,
"reason": "missing_from_model_output",
}
decisions.append(decision)
return decisions
def load_existing_reviews(path: Path) -> dict[int, dict]:
existing = {}
if not path.exists():
return existing
with path.open() as f:
for line in f:
line = line.strip()
if not line:
continue
item = json.loads(line)
existing[int(item["row_id"])] = item
return existing
def review_rows(
rows: list[dict],
args: argparse.Namespace,
client_ctx: dict,
references: list[dict],
reviews_path: Path,
) -> dict[int, dict]:
existing = load_existing_reviews(reviews_path) if args.resume else {}
pending = [row for row in rows if row["row_id"] not in existing]
total = len(rows)
if pending:
reviews_path.parent.mkdir(parents=True, exist_ok=True)
with reviews_path.open("a") as reviews_file:
with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
futures = {}
pending_batches = [
pending[start : start + args.batch_size]
for start in range(0, len(pending), args.batch_size)
]
next_batch_idx = 0
while (
next_batch_idx < len(pending_batches)
and len(futures) < args.concurrency
):
batch = pending_batches[next_batch_idx]
future = executor.submit(
score_batch, client_ctx, args, references, batch
)
futures[future] = batch
next_batch_idx += 1
completed = 0
while futures:
done, _ = wait(futures.keys(), return_when=FIRST_COMPLETED)
for future in done:
batch = futures.pop(future)
decisions = future.result()
for row, decision in zip(batch, decisions):
review = {
"row_id": row["row_id"],
"query": row["query"],
"source_file": row["source_file"],
"source_line": row["source_line"],
**decision,
}
existing[row["row_id"]] = review
reviews_file.write(
json.dumps(review, ensure_ascii=False) + "\n"
)
reviews_file.flush()
completed += len(batch)
print(
f"Reviewed {len(existing)}/{total} rows "
f"(new {completed}/{len(pending)})",
flush=True,
)
if next_batch_idx < len(pending_batches):
next_batch = pending_batches[next_batch_idx]
next_future = executor.submit(
score_batch, client_ctx, args, references, next_batch
)
futures[next_future] = next_batch
next_batch_idx += 1
return existing
def candidate_priority(review: dict) -> tuple:
return (
int(review["keep"]),
int(review["naturalness"]) + int(review["simpleqa_style_fit"]),
int(review["simpleqa_style_fit"]),
int(review["naturalness"]),
-int(review["row_id"]),
)
def select_rows(
rows: list[dict], reviews: dict[int, dict], args: argparse.Namespace
) -> list[dict]:
candidates = []
for row in rows:
review = reviews.get(row["row_id"])
if not review:
continue
direct_keep = (
review["keep"]
and review["naturalness"] >= args.min_naturalness
and review["simpleqa_style_fit"] >= args.min_style_fit
)
if direct_keep:
candidates.append((row, review))
if args.dedupe_query:
by_query = {}
for row, review in candidates:
key = normalize_query(row["query"])
current = by_query.get(key)
if current is None or candidate_priority(review) > candidate_priority(
current[1]
):
by_query[key] = (row, review)
candidates = list(by_query.values())
candidates.sort(key=lambda item: candidate_priority(item[1]), reverse=True)
if args.target_count > 0 and len(candidates) > args.target_count:
candidates = candidates[: args.target_count]
return [row for row, _ in candidates]
def word_count(query: str) -> int:
return len(re.findall(r"[A-Za-z0-9']+", query))
def compute_query_stats(queries: list[str]) -> dict:
if not queries:
return {
"count": 0,
"avg_words": 0.0,
"avg_chars": 0.0,
"top_starts": [],
"has_quote_pct": 0.0,
"has_year_pct": 0.0,
}
starts = Counter(question_start_bucket(query) for query in queries)
word_counts = [word_count(query) for query in queries]
char_counts = [len(query) for query in queries]
has_quote = sum('"' in query or "'" in query for query in queries)
has_year = sum(
bool(re.search(r"\b(1[0-9]{3}|20[0-2][0-9])\b", query)) for query in queries
)
return {
"count": len(queries),
"avg_words": round(sum(word_counts) / len(word_counts), 2),
"avg_chars": round(sum(char_counts) / len(char_counts), 2),
"top_starts": starts.most_common(12),
"has_quote_pct": round(100 * has_quote / len(queries), 2),
"has_year_pct": round(100 * has_year / len(queries), 2),
}
def estimated_cost_usd(model: str, usage: dict) -> float | None:
pricing = MODEL_PRICING.get(model)
if not pricing:
return None
in_cost = usage["prompt_tokens"] / 1_000_000 * pricing["input_per_m"]
out_cost = usage["completion_tokens"] / 1_000_000 * pricing["output_per_m"]
return round(in_cost + out_cost, 6)
def write_jsonl(path: Path, rows: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w") as f:
for row in rows:
f.write(json.dumps(row["payload"], ensure_ascii=False) + "\n")
def main() -> int:
args = parse_args()
output_path = Path(args.output)
reviews_path = (
Path(args.reviews_output)
if args.reviews_output
else output_path.with_suffix(".reviews.jsonl")
)
summary_path = (
Path(args.summary_output)
if args.summary_output
else output_path.with_suffix(".summary.json")
)
random.seed(args.seed)
rows = load_input_rows(args)
print(f"Loaded {len(rows)} rows from {args.input_glob}", flush=True)
references = load_simpleqa_references(
Path(args.simpleqa_path), args.few_shot_count, args.seed
)
print(f"Loaded {len(references)} SimpleQA reference examples", flush=True)
client_ctx = build_client(args)
reviews = review_rows(rows, args, client_ctx, references, reviews_path)
selected = select_rows(rows, reviews, args)
write_jsonl(output_path, selected)
input_queries = [row["query"] for row in rows]
output_queries = [row["query"] for row in selected]
summary = {
"model": args.model,
"input_glob": args.input_glob,
"simpleqa_path": args.simpleqa_path,
"total_input_rows": len(rows),
"reviewed_rows": len(reviews),
"selected_rows": len(selected),
"target_count": args.target_count,
"dedupe_query": args.dedupe_query,
"min_naturalness": args.min_naturalness,
"min_style_fit": args.min_style_fit,
"batch_size": args.batch_size,
"concurrency": args.concurrency,
"query_stats_before": compute_query_stats(input_queries),
"query_stats_after": compute_query_stats(output_queries),
"usage": {
**client_ctx["usage"],
"estimated_cost_usd": estimated_cost_usd(args.model, client_ctx["usage"]),
},
}
summary_path.parent.mkdir(parents=True, exist_ok=True)
summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n")
print(
json.dumps(
{
"output": str(output_path),
"reviews_output": str(reviews_path),
"summary_output": str(summary_path),
"selected_rows": len(selected),
"estimated_cost_usd": summary["usage"]["estimated_cost_usd"],
},
indent=2,
ensure_ascii=False,
),
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())