startrail-org--pixelrag
613 行
21 KiB
Python
613 行
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate synthetic query-chunk pairs for contrastive learning.
|
|
|
|
Samples informative Wikipedia pages from kiwix tiles, sends screenshot chunks
|
|
to Gemini to generate factual Q&A pairs with source_type labels.
|
|
|
|
Output: JSONL with {query, answer, source_type, subject, source_sentence,
|
|
chunk_path, url, title, chunk_index, tiles_dir}
|
|
|
|
Prerequisites:
|
|
- kiwix_tiles directory with Wikipedia screenshot tiles and index.jsonl
|
|
- Google Cloud ADC (gcloud auth application-default login) for Vertex AI,
|
|
OR set GOOGLE_API_KEY for direct Gemini API access
|
|
|
|
Usage:
|
|
python generate_query_pairs.py \
|
|
--tiles-dir /path/to/kiwix_tiles \
|
|
--num-pages 1000 \
|
|
--output batches/batch_000.jsonl
|
|
|
|
# Batched generation (non-overlapping slices):
|
|
python generate_query_pairs.py \
|
|
--tiles-dir /path/to/kiwix_tiles \
|
|
--batch-index 0 --total-batches 100 \
|
|
--num-pages 2000 \
|
|
--output batches/batch_000.jsonl
|
|
|
|
Ported from Vis-RAG/agent/scripts/contrastive/generate_query_pairs.py
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import random
|
|
import base64
|
|
import asyncio
|
|
import argparse
|
|
import time
|
|
from pathlib import Path
|
|
from io import BytesIO
|
|
from collections import Counter
|
|
|
|
from PIL import Image
|
|
from google import genai
|
|
from google.genai.types import HttpOptions, GenerateContentConfig
|
|
|
|
# Vertex AI config — requires gcloud ADC (gcloud auth application-default login)
|
|
os.environ.setdefault("GOOGLE_CLOUD_PROJECT", "wise-coyote-478119-h0")
|
|
os.environ.setdefault("GOOGLE_GENAI_USE_VERTEXAI", "true")
|
|
os.environ.setdefault("GOOGLE_CLOUD_LOCATION", "global")
|
|
|
|
MODEL_PRICING = {
|
|
"gemini-3.1-pro-preview": (1.25, 10.00),
|
|
"gemini-2.5-pro-preview-03-25": (1.25, 10.00),
|
|
"gemini-2.5-pro": (1.25, 10.00),
|
|
"gemini-3.1-flash-lite-preview": (1.00, 4.00),
|
|
"gemini-2.0-flash-001": (0.10, 0.40),
|
|
"gemini-2.0-flash": (0.10, 0.40),
|
|
}
|
|
DEFAULT_MODEL = "gemini-3.1-pro-preview"
|
|
MAX_CONCURRENT = 40
|
|
IMG_JPEG_QUALITY = 85
|
|
|
|
# ── Page filtering ───────────────────────────────────────────────────
|
|
SKIP_PATTERNS = [
|
|
r"disambiguation",
|
|
r"category:",
|
|
r"template:",
|
|
r"wikipedia:",
|
|
r"portal:",
|
|
r"file:",
|
|
r"help:",
|
|
r"talk:",
|
|
r"module:",
|
|
r"draft:",
|
|
r"_deaths$",
|
|
r"_births$",
|
|
]
|
|
|
|
SKIP_CONTENT_PATTERNS = [
|
|
r"\belection\b",
|
|
r"\belections\b",
|
|
r"\breferendum\b",
|
|
r"\bprimary\b",
|
|
r"\bby-election\b",
|
|
r"\bcouncil election\b",
|
|
r"^list of ",
|
|
r"^lists of ",
|
|
r"\bdiscography\b",
|
|
r"\bfilmography\b",
|
|
r"\btrack listing\b",
|
|
r"_discography$",
|
|
r"_filmography$",
|
|
r"\bseason\b.*\bleague\b",
|
|
r"\bleague season\b",
|
|
r"\bfootball league\b",
|
|
r"\bnba season\b",
|
|
r"\bnfl season\b",
|
|
r"\bcensus\b",
|
|
r"\bdemographic\b",
|
|
r"^list of .* episodes",
|
|
r"\bepisodes of\b",
|
|
r"\bgovernors? of\b",
|
|
r"\bmayors? of\b",
|
|
r"\bprime ministers? of\b",
|
|
r"\bcareer statistics\b",
|
|
r"\bplayer statistics\b",
|
|
]
|
|
|
|
SKIP_RE = [re.compile(p, re.IGNORECASE) for p in SKIP_PATTERNS]
|
|
SKIP_CONTENT_RE = [re.compile(p, re.IGNORECASE) for p in SKIP_CONTENT_PATTERNS]
|
|
|
|
BAD_QUESTION_PATTERNS = [
|
|
r"\baccording to the\b",
|
|
r"\baccording to this\b",
|
|
r"\bvisible\b",
|
|
r"\btrack listing\b",
|
|
r"\blisted (here|above|below|in the table)\b",
|
|
r"\bthe table (shows|lists|above|below)\b",
|
|
r"\bin the (following|above) table\b",
|
|
r"\bshown in\b",
|
|
r"^what is (the )?listed",
|
|
r"^what (are|is) listed",
|
|
r"\bpage (shows|lists|includes)\b",
|
|
r"\bthe film\b(?!\s+[A-Z\"])",
|
|
r"\bthe song\b(?!\s+[A-Z\"])",
|
|
r"\bthe album\b(?!\s+[A-Z\"])",
|
|
r"\bthe book\b(?!\s+[A-Z\"])",
|
|
r"\bthe team\b(?!\s+[A-Z\"])",
|
|
r"\bthe show\b(?!\s+[A-Z\"])",
|
|
r"\bthe series\b(?!\s+[A-Z\"])",
|
|
r"\bthe station\b(?!\s+[A-Z\"])",
|
|
r"\bthe school\b(?!\s+[A-Z\"])",
|
|
r"\bthe match\b(?!\s+[A-Z\"])",
|
|
r"\bthe game\b(?!\s+[A-Z\"])",
|
|
r"\bthe competition\b(?!\s+[A-Z\"])",
|
|
r"\bthe episode\b(?!\s+[A-Z\"\d])",
|
|
r"\bthe production\b(?!\s+[A-Z\"])",
|
|
r"\bthe tournament\b(?!\s+[A-Z\d\"])",
|
|
r"^(when|where|who|what|how|why) (was|is|were|did|does|has|have) (it|they|this|that|he|she)\b",
|
|
]
|
|
BAD_Q_RE = [re.compile(p, re.IGNORECASE) for p in BAD_QUESTION_PATTERNS]
|
|
|
|
|
|
def get_page_chunk_count(entry: dict, tiles_root: Path) -> int:
|
|
cached = entry.get("_chunk_count")
|
|
if cached is not None:
|
|
return cached
|
|
|
|
tiles_dir = tiles_root / entry["tiles_dir"]
|
|
chunks_json = tiles_dir / "chunks.json"
|
|
if not chunks_json.exists():
|
|
entry["_chunk_count"] = 0
|
|
return 0
|
|
|
|
with open(chunks_json) as f:
|
|
meta = json.load(f)
|
|
|
|
chunk_count = len(meta.get("chunks", []))
|
|
entry["_chunk_count"] = chunk_count
|
|
return chunk_count
|
|
|
|
|
|
def is_informative_page(entry: dict) -> bool:
|
|
if entry.get("page_height", 0) < 3000:
|
|
return False
|
|
if entry.get("num_tiles", 0) < 1:
|
|
return False
|
|
if not entry.get("complete", False):
|
|
return False
|
|
|
|
title_lower = entry["title"].lower()
|
|
url_lower = entry.get("url", "").lower()
|
|
check = title_lower + " " + url_lower
|
|
|
|
for pat in SKIP_RE:
|
|
if pat.search(check):
|
|
return False
|
|
for pat in SKIP_CONTENT_RE:
|
|
if pat.search(check):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def is_natural_question(qa: dict) -> bool:
|
|
q = qa.get("query", "")
|
|
for pat in BAD_Q_RE:
|
|
if pat.search(q):
|
|
return False
|
|
a = qa.get("answer", "")
|
|
if a and a[-1] in ("→", "…", "–", "/", "(", ","):
|
|
return False
|
|
s = qa.get("source_sentence", "") or ""
|
|
src_type = qa.get("source_type", "prose")
|
|
if not s:
|
|
return False
|
|
if s.rstrip()[-1:] in ("(", ",", "–", "/", "→", "…"):
|
|
return False
|
|
if src_type == "prose" and len(s.split()) < 10:
|
|
return False
|
|
if src_type in ("infobox", "table") and len(s.split()) < 3:
|
|
return False
|
|
return True
|
|
|
|
|
|
def load_and_sample_pages(
|
|
index_path: Path,
|
|
n: int,
|
|
batch_index: int = 0,
|
|
total_batches: int = 1,
|
|
) -> list:
|
|
MASTER_SEED = 0
|
|
print(f"Loading index from {index_path}...")
|
|
candidates: list = []
|
|
|
|
with open(index_path) as f:
|
|
for line in f:
|
|
entry = json.loads(line)
|
|
if is_informative_page(entry):
|
|
candidates.append(entry)
|
|
|
|
print(f"Total eligible: {len(candidates):,} pages")
|
|
|
|
rng = random.Random(MASTER_SEED)
|
|
rng.shuffle(candidates)
|
|
|
|
slice_size = len(candidates) // total_batches
|
|
start = batch_index * slice_size
|
|
end = start + slice_size if batch_index < total_batches - 1 else len(candidates)
|
|
pool = candidates[start:end]
|
|
|
|
print(
|
|
f"Batch {batch_index}/{total_batches}: pages [{start}:{end}] ({len(pool):,} in pool)"
|
|
)
|
|
selected = pool[:n] if n <= len(pool) else pool
|
|
return selected
|
|
|
|
|
|
def filter_selected_pages_by_chunk_count(
|
|
pages: list[dict],
|
|
tiles_root: Path,
|
|
min_page_chunks: int | None = None,
|
|
max_page_chunks: int | None = None,
|
|
) -> list[dict]:
|
|
if min_page_chunks is None and max_page_chunks is None:
|
|
return pages
|
|
|
|
kept = []
|
|
for entry in pages:
|
|
chunk_count = get_page_chunk_count(entry, tiles_root)
|
|
if min_page_chunks is not None and chunk_count < min_page_chunks:
|
|
continue
|
|
if max_page_chunks is not None and chunk_count > max_page_chunks:
|
|
continue
|
|
kept.append(entry)
|
|
return kept
|
|
|
|
|
|
def pick_random_chunk(entry: dict, tiles_root: Path) -> tuple:
|
|
tiles_dir = tiles_root / entry["tiles_dir"]
|
|
chunks_json = tiles_dir / "chunks.json"
|
|
|
|
if not chunks_json.exists():
|
|
return None, None
|
|
|
|
with open(chunks_json) as f:
|
|
meta = json.load(f)
|
|
|
|
chunks = meta.get("chunks", [])
|
|
if not chunks:
|
|
return None, None
|
|
|
|
usable = chunks[: max(1, int(len(chunks) * 0.7))]
|
|
chunk = random.choice(usable)
|
|
chunk_path = tiles_dir / chunk["file"]
|
|
|
|
if not chunk_path.exists():
|
|
return None, None
|
|
|
|
return str(chunk_path), chunk["chunk_index"]
|
|
|
|
|
|
def encode_image(path: str) -> str:
|
|
img = Image.open(path)
|
|
buf = BytesIO()
|
|
img.convert("RGB").save(buf, format="JPEG", quality=IMG_JPEG_QUALITY)
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
QUERY_PROMPT = """\
|
|
You are generating a query–evidence pair for training a visual retrieval model over Wikipedia screenshot chunks.
|
|
|
|
TASK: Given this screenshot chunk, generate ONE factual question whose answer is explicitly and completely visible in this chunk.
|
|
|
|
━━━ STYLE — write questions like real search queries, not templates ━━━
|
|
Good questions sound like something a curious person would actually search for online.
|
|
Vary the phrasing — use "how much", "in what year", "which", "who", "where", "what caused", "how long", etc.
|
|
|
|
Here are examples of the STYLE we want (from SimpleQA benchmark):
|
|
✓ "How much money, in euros, was the surgeon held responsible for paying in the Olivia Puls case?"
|
|
✓ "In what city was the 2010 FIFA World Cup opening ceremony held?"
|
|
✓ "How many days did the 1906 San Francisco earthquake fire burn?"
|
|
✓ "Which award did Fullmetal Alchemist win at the American Anime Awards in 2007?"
|
|
✓ "Who was the first Black female judge appointed to the Cook County Circuit Court?"
|
|
✓ "What was the name of the ship that sank during the 1994 Estonia ferry disaster?"
|
|
|
|
━━━ EVIDENCE SOURCE — be diverse ━━━
|
|
You may draw from ANY visible content: prose text, infobox fields, table cells, image captions, diagrams, or photographs.
|
|
Do not always default to infobox — choose whichever source produces the most natural, interesting question.
|
|
|
|
━━━ HARD RULES ━━━
|
|
1. SELF-CONTAINED: The question must be fully understandable on its own — no page title, no external context needed.
|
|
Every entity in the question must be named explicitly.
|
|
|
|
✗ "Who composed the music for the film?" → missing film name
|
|
✗ "What is Rideaux's occupation?" → surname only, who is Rideaux?
|
|
✗ "On what date was Lerew awarded the DFC?" → surname only + unexplained acronym
|
|
✗ "Which medication is listed as a SARM in the provided table?" → depends on "the provided table"
|
|
✗ "Who played the actress in the 2013 film Horns?" → "the actress" not identified
|
|
✗ "When was the SMN founded?" → unexplained acronym
|
|
✗ "Who was the spouse of John Houston?" → too ambiguous, which John Houston?
|
|
✗ "Which cyclist placed second in the Tempo race?" → missing event/year context
|
|
✗ "What is listed in the infobox?" → references page layout
|
|
✗ "Which mission is shown in the screenshot?" → depends on visual layout
|
|
✗ "Which former Cleveland Indians player did the Seattle Mariners sign on December 20?" → missing year
|
|
✗ "Who was Kesha Rogers' opponent in the general election?" → missing year and race context
|
|
|
|
✓ "Who composed the music for Once Upon a Time in Hong Kong?"
|
|
✓ "What is the occupation of Rabbi Shmuel Kamenetsky?"
|
|
✓ "In what year did photographer Clarence Rideaux found the agency PicturePerfect?"
|
|
✓ "On what date was RAF pilot Arthur Lerew awarded the Distinguished Flying Cross in World War II?"
|
|
✓ "Which former Cleveland Indians pitcher did the Seattle Mariners sign on December 20, 2004?"
|
|
|
|
2. EVIDENCE COMPLETE: The answer must be fully visible in this chunk — not guessed or inferred.
|
|
The source sentence (S:) must be a complete, untruncated span.
|
|
|
|
3. DISTINCTIVE: Include enough specifics (names, dates, locations, titles) to distinguish this chunk from similar pages.
|
|
|
|
━━━ ANSWER ━━━
|
|
Prefer a single concise entity: name, date, place, number, title, or short phrase.
|
|
|
|
━━━ SKIP if any is true ━━━
|
|
- Raw vote counts, track listings, census tables, or episode lists
|
|
- Answer not fully visible or requires external context
|
|
- Cannot write a self-contained question naming all entities
|
|
- Source sentence is truncated or a fragment
|
|
|
|
Write exactly: SKIP
|
|
|
|
source_type: image | table | infobox | prose
|
|
subject: science | medicine | history | geography | technology | education | culture | politics | economics | biology | sports | entertainment | other
|
|
|
|
Output format (5 lines only):
|
|
Q: <natural, self-contained question>
|
|
A: <concise answer>
|
|
S: <verbatim complete span from the chunk>
|
|
T: <source_type>
|
|
C: <subject>"""
|
|
|
|
|
|
async def generate_qa(
|
|
client: genai.Client,
|
|
model: str,
|
|
chunk_path: str,
|
|
semaphore: asyncio.Semaphore,
|
|
token_counter: dict,
|
|
) -> dict | None:
|
|
async with semaphore:
|
|
b64 = encode_image(chunk_path)
|
|
|
|
contents = [
|
|
{
|
|
"role": "user",
|
|
"parts": [
|
|
{"text": QUERY_PROMPT},
|
|
{"inline_data": {"mime_type": "image/jpeg", "data": b64}},
|
|
],
|
|
}
|
|
]
|
|
config = GenerateContentConfig(temperature=0.7, max_output_tokens=1024)
|
|
|
|
for attempt in range(5):
|
|
try:
|
|
t0 = time.time()
|
|
resp = await asyncio.get_event_loop().run_in_executor(
|
|
None,
|
|
lambda: client.models.generate_content(
|
|
model=model,
|
|
contents=contents,
|
|
config=config,
|
|
),
|
|
)
|
|
elapsed = time.time() - t0
|
|
|
|
usage = resp.usage_metadata
|
|
if usage:
|
|
token_counter["input"] += getattr(usage, "prompt_token_count", 0)
|
|
token_counter["output"] += getattr(
|
|
usage, "candidates_token_count", 0
|
|
)
|
|
token_counter["calls"] += 1
|
|
token_counter["total_time"] += elapsed
|
|
|
|
text = ""
|
|
for p in resp.candidates[0].content.parts:
|
|
raw = getattr(p, "text", None)
|
|
if raw and not getattr(p, "thought", False):
|
|
text = raw.strip()
|
|
|
|
if not text or text.strip() == "SKIP":
|
|
return None
|
|
|
|
fields = {}
|
|
for line in text.split("\n"):
|
|
line = line.strip()
|
|
for prefix, key in [
|
|
("Q:", "query"),
|
|
("A:", "answer"),
|
|
("S:", "source_sentence"),
|
|
("T:", "source_type"),
|
|
("C:", "subject"),
|
|
]:
|
|
if line.startswith(prefix):
|
|
fields[key] = line[len(prefix) :].strip()
|
|
break
|
|
|
|
q = fields.get("query")
|
|
a = fields.get("answer")
|
|
if not q or not a or len(a) < 2:
|
|
return None
|
|
|
|
qa = {
|
|
"query": q,
|
|
"answer": a,
|
|
"source_sentence": fields.get("source_sentence"),
|
|
"source_type": fields.get("source_type", "prose"),
|
|
"subject": fields.get("subject", "other"),
|
|
}
|
|
|
|
if not is_natural_question(qa):
|
|
return None
|
|
|
|
return qa
|
|
|
|
except Exception as e:
|
|
err = str(e)
|
|
if "429" in err or "RESOURCE_EXHAUSTED" in err:
|
|
wait = 2**attempt * 15 + random.uniform(1, 5)
|
|
print(f" Rate limited, waiting {wait:.0f}s...")
|
|
await asyncio.sleep(wait)
|
|
elif attempt < 4:
|
|
await asyncio.sleep(2)
|
|
else:
|
|
print(f" Failed after 5 attempts: {e}")
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
async def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate synthetic query-chunk pairs from Wikipedia screenshot tiles"
|
|
)
|
|
parser.add_argument(
|
|
"--tiles-dir",
|
|
type=Path,
|
|
required=True,
|
|
help="Root directory of kiwix_tiles (containing index.jsonl)",
|
|
)
|
|
parser.add_argument("--num-pages", type=int, default=10)
|
|
parser.add_argument("--output", type=str, default="query_pairs.jsonl")
|
|
parser.add_argument("--seed", type=int, default=42)
|
|
parser.add_argument(
|
|
"--model",
|
|
type=str,
|
|
default=DEFAULT_MODEL,
|
|
help="Gemini model (e.g. gemini-3.1-pro-preview, gemini-2.0-flash-001)",
|
|
)
|
|
parser.add_argument(
|
|
"--batch-index",
|
|
type=int,
|
|
default=0,
|
|
help="Which non-overlapping batch to process (0-based)",
|
|
)
|
|
parser.add_argument(
|
|
"--total-batches",
|
|
type=int,
|
|
default=1,
|
|
help="Total number of batches the candidate pool is divided into",
|
|
)
|
|
parser.add_argument("--max-concurrent", type=int, default=MAX_CONCURRENT)
|
|
parser.add_argument("--postfilter-min-page-chunks", type=int, default=None)
|
|
parser.add_argument("--postfilter-max-page-chunks", type=int, default=None)
|
|
args = parser.parse_args()
|
|
|
|
random.seed(args.seed)
|
|
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
|
|
|
tiles_root = args.tiles_dir
|
|
index_path = tiles_root / "index.jsonl"
|
|
if not index_path.exists():
|
|
raise FileNotFoundError(f"index.jsonl not found at {index_path}")
|
|
|
|
model = args.model
|
|
price_input, price_output = MODEL_PRICING.get(model, (1.25, 10.00))
|
|
|
|
print(
|
|
f"Model: {model} via Vertex AI (project={os.environ.get('GOOGLE_CLOUD_PROJECT', 'N/A')})"
|
|
)
|
|
|
|
pages = load_and_sample_pages(
|
|
index_path, args.num_pages, args.batch_index, args.total_batches
|
|
)
|
|
pages_before_postfilter = len(pages)
|
|
pages = filter_selected_pages_by_chunk_count(
|
|
pages,
|
|
tiles_root,
|
|
min_page_chunks=args.postfilter_min_page_chunks,
|
|
max_page_chunks=args.postfilter_max_page_chunks,
|
|
)
|
|
if (
|
|
args.postfilter_min_page_chunks is not None
|
|
or args.postfilter_max_page_chunks is not None
|
|
):
|
|
chunk_filter = []
|
|
if args.postfilter_min_page_chunks is not None:
|
|
chunk_filter.append(f"chunks>={args.postfilter_min_page_chunks}")
|
|
if args.postfilter_max_page_chunks is not None:
|
|
chunk_filter.append(f"chunks<={args.postfilter_max_page_chunks}")
|
|
print(
|
|
"Post-slice page filter: "
|
|
+ ", ".join(chunk_filter)
|
|
+ f" -> kept {len(pages)}/{pages_before_postfilter} pages"
|
|
)
|
|
print(f"Selected {len(pages)} pages\n")
|
|
|
|
semaphore = asyncio.Semaphore(args.max_concurrent)
|
|
token_counter = {"input": 0, "output": 0, "calls": 0, "total_time": 0.0}
|
|
results = []
|
|
|
|
client = genai.Client(http_options=HttpOptions(api_version="v1"))
|
|
|
|
async def process_one(page, chunk_path, chunk_idx):
|
|
qa = await generate_qa(client, model, chunk_path, semaphore, token_counter)
|
|
if not qa:
|
|
return None
|
|
rel_path = str(Path(chunk_path).relative_to(tiles_root))
|
|
return {
|
|
**qa,
|
|
"chunk_path": rel_path,
|
|
"url": page["url"],
|
|
"title": page["title"],
|
|
"chunk_index": chunk_idx,
|
|
"tiles_dir": page["tiles_dir"],
|
|
}
|
|
|
|
work_items = []
|
|
for page in pages:
|
|
chunk_path, chunk_idx = pick_random_chunk(page, tiles_root)
|
|
if chunk_path is None:
|
|
continue
|
|
work_items.append((page, chunk_path, chunk_idx))
|
|
|
|
print(f"Generating Q&A for {len(work_items)} chunks...\n")
|
|
t_start = time.time()
|
|
|
|
tasks = [asyncio.ensure_future(process_one(p, cp, ci)) for p, cp, ci in work_items]
|
|
|
|
with open(args.output, "w") as outf:
|
|
for coro in asyncio.as_completed(tasks):
|
|
result = await coro
|
|
if result:
|
|
results.append(result)
|
|
outf.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
outf.flush()
|
|
st = result.get("source_type", "?")
|
|
subj = result.get("subject", "?")
|
|
print(f" [{st:8s}|{subj:14s}] {result['title']}")
|
|
print(f" Q: {result['query'][:90]}")
|
|
print(f" A: {result['answer'][:90]}")
|
|
print()
|
|
|
|
wall_time = time.time() - t_start
|
|
|
|
type_dist = Counter(r["source_type"] for r in results)
|
|
subj_dist = Counter(r["subject"] for r in results)
|
|
|
|
in_tok = token_counter["input"]
|
|
out_tok = token_counter["output"]
|
|
calls = token_counter["calls"]
|
|
cost = (in_tok / 1e6 * price_input) + (out_tok / 1e6 * price_output)
|
|
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Wrote {len(results)} Q&A pairs to {args.output}")
|
|
print(f"Source types: {dict(type_dist)}")
|
|
print(f"Subjects: {dict(subj_dist)}")
|
|
print(f"\n--- Throughput & Cost ({model}) ---")
|
|
print(f" Calls: {calls}")
|
|
print(f" Input tokens: {in_tok:,} ({in_tok / max(calls, 1):.0f} avg/call)")
|
|
print(f" Output tokens:{out_tok:,} ({out_tok / max(calls, 1):.0f} avg/call)")
|
|
print(f" Wall time: {wall_time:.1f}s ({wall_time / max(calls, 1):.1f}s/call)")
|
|
print(f" Est. cost: ${cost:.4f} for {calls} calls")
|
|
print(f" Per 10 pairs: ${cost / max(calls, 1) * 10:.4f}")
|
|
print(f" Per 1K pairs: ${cost / max(calls, 1) * 1000:.2f}")
|
|
print(f" Per 50K pairs:${cost / max(calls, 1) * 50000:.0f}")
|
|
print(f"{'=' * 60}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|