Files
ietf-draft-analyzer/scripts/rerate-intercoder.py

227 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Inter-coder re-rating for the IETF AI/agent landscape survey.
Re-rates the clean IETF corpus (source='ietf', not false-positive) with TWO
models (Sonnet + Haiku) using the EXACT pinned production prompt
(``RATE_PROMPT_COMPACT``, abstract[:2000]) via the Anthropic Batch API (50% off).
Safety / reproducibility:
- Does NOT touch the production ``ratings`` table. Output goes to
``data/rerate/<model-alias>.jsonl`` (one JSON object per draft).
- Batch IDs are persisted to ``data/rerate/batches.json`` so the run resumes.
- Idempotent: drafts already present in the output JSONL are skipped on re-submit.
Usage:
PYTHONPATH=src python3 scripts/rerate-intercoder.py --dry-run # cost estimate, submit nothing
PYTHONPATH=src python3 scripts/rerate-intercoder.py --submit # create batches
PYTHONPATH=src python3 scripts/rerate-intercoder.py --collect # poll + write results
PYTHONPATH=src python3 scripts/rerate-intercoder.py --run # submit then collect (blocking)
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from anthropic import Anthropic
from ietf_analyzer.analyzer import (
CATEGORIES_SHORT,
RATE_PROMPT_COMPACT,
_doc_type_label,
)
from ietf_analyzer.config import Config
from ietf_analyzer.db import Database
OUT_DIR = Path("data/rerate")
BATCH_FILE = OUT_DIR / "batches.json"
MAX_TOKENS = 512
# Anthropic batch pricing is 50% of standard. Standard (USD per 1M tokens):
PRICING = { # input, output
"sonnet": (3.00, 15.00),
"haiku": (1.00, 5.00),
}
MODELS = {
"sonnet": lambda c: c.claude_model,
"haiku": lambda c: c.claude_model_cheap,
}
def clean_ietf_drafts(db: Database):
"""The survey corpus: source='ietf', not flagged false-positive."""
rows = db.conn.execute(
"""SELECT d.name FROM drafts d JOIN ratings r ON d.name = r.draft_name
WHERE d.source = 'ietf'
AND (r.false_positive = 0 OR r.false_positive IS NULL)
ORDER BY d.name"""
).fetchall()
return [r[0] for r in rows]
def build_prompt(db: Database, name: str) -> str | None:
d = db.get_draft(name)
if d is None:
return None
return RATE_PROMPT_COMPACT.format(
doc_type=_doc_type_label(d.source),
name=d.name, title=d.title, time=d.date,
pages=d.pages or "?",
abstract=d.abstract[:2000],
categories=", ".join(CATEGORIES_SHORT),
)
def already_done(alias: str) -> set[str]:
p = OUT_DIR / f"{alias}.jsonl"
if not p.exists():
return set()
done = set()
for line in p.read_text().splitlines():
if line.strip():
done.add(json.loads(line)["draft_name"])
return done
def load_batches() -> dict:
if BATCH_FILE.exists():
return json.loads(BATCH_FILE.read_text())
return {}
def save_batches(b: dict):
OUT_DIR.mkdir(parents=True, exist_ok=True)
BATCH_FILE.write_text(json.dumps(b, indent=2))
def cmd_dry_run(db: Database, cfg: Config, names: list[str]):
total_in = 0
sample = None
for n in names:
p = build_prompt(db, n)
if p is None:
continue
total_in += len(p) // 4 # ~4 chars/token
if sample is None:
sample = p
out_est = len(names) * 350 # observed compact-JSON output size
print(f"corpus: {len(names)} clean IETF drafts")
print(f"est input tokens/draft: ~{total_in // max(len(names),1)}")
print(f"est total input tokens: ~{total_in:,} | output: ~{out_est:,}")
print("\nestimated cost (Batch API = 50% of standard):")
grand = 0.0
for alias, (pin, pout) in PRICING.items():
c = (total_in / 1e6 * pin + out_est / 1e6 * pout) * 0.5
grand += c
print(f" {alias:7} ({MODELS[alias](cfg)}): ${c:.2f}")
print(f" {'BOTH':7}: ${grand:.2f}")
print(f"\n--- sample prompt ({len(sample)} chars) ---\n{sample[:600]}\n...")
def cmd_submit(db: Database, cfg: Config, names: list[str], client: Anthropic):
batches = load_batches()
for alias in MODELS:
if batches.get(alias, {}).get("id"):
print(f"[{alias}] batch already submitted: {batches[alias]['id']} — skip")
continue
done = already_done(alias)
todo = [n for n in names if n not in done]
if not todo:
print(f"[{alias}] all {len(names)} already collected — nothing to submit")
continue
model = MODELS[alias](cfg)
requests = []
for n in todo:
p = build_prompt(db, n)
if p is None:
continue
requests.append({
"custom_id": n,
"params": {
"model": model,
"max_tokens": MAX_TOKENS,
"messages": [{"role": "user", "content": p}],
},
})
batch = client.messages.batches.create(requests=requests)
batches[alias] = {"id": batch.id, "model": model, "count": len(requests)}
save_batches(batches)
print(f"[{alias}] submitted {len(requests)} requests → batch {batch.id}")
def cmd_collect(client: Anthropic, poll: bool):
batches = load_batches()
if not batches:
print("no batches submitted yet (run --submit)")
return False
all_done = True
for alias, info in batches.items():
bid = info["id"]
b = client.messages.batches.retrieve(bid)
print(f"[{alias}] {bid}: {b.processing_status} "
f"(succeeded={b.request_counts.succeeded}, errored={b.request_counts.errored}, "
f"processing={b.request_counts.processing})")
if b.processing_status != "ended":
all_done = False
continue
out_path = OUT_DIR / f"{alias}.jsonl"
done = already_done(alias)
n_new = 0
with out_path.open("a") as f:
for result in client.messages.batches.results(bid):
cid = result.custom_id
if cid in done:
continue
if result.result.type != "succeeded":
f.write(json.dumps({"draft_name": cid, "error": result.result.type}) + "\n")
continue
msg = result.result.message
raw = msg.content[0].text
rec = {
"draft_name": cid,
"model": info["model"],
"raw": raw,
"in_tok": msg.usage.input_tokens,
"out_tok": msg.usage.output_tokens,
}
f.write(json.dumps(rec) + "\n")
n_new += 1
print(f"[{alias}] wrote {n_new} new results → {out_path}")
return all_done
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--submit", action="store_true")
ap.add_argument("--collect", action="store_true")
ap.add_argument("--run", action="store_true", help="submit then poll-collect until done")
args = ap.parse_args()
cfg = Config.load()
db = Database(cfg)
names = clean_ietf_drafts(db)
if args.dry_run:
cmd_dry_run(db, cfg, names)
return
client = Anthropic()
if args.submit or args.run:
cmd_submit(db, cfg, names, client)
if args.collect:
cmd_collect(client, poll=False)
if args.run:
while True:
if cmd_collect(client, poll=True):
print("all batches ended.")
break
print("...waiting 60s for batches to finish")
time.sleep(60)
if __name__ == "__main__":
sys.exit(main())