227 lines
7.5 KiB
Python
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())
|