textmachine/eval/exp16/coldstart_gen.py

151 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""exp16 — cold-start / A4 generation (research/20 §D1 cold-start slice + §D2 A4). PAID (deepseek-v4-flash).
FROZEN in the pre-reg commit; RUN later in the DeepSeek off-peak valley (D39.7: peaks UTC 01-04 & 06-10
cost ×2 — this script REFUSES to run in a peak window unless --force). Generates, for the pre-registered
cold-start chapters [1,4,5,7,9] (14 chunks), fresh flash drafts WITHOUT glossary injection, in two configs:
plain — cold-start baseline (translator.md brief-filled, NO glossary block) -> spread + canon-recovery
banknote — plain + the banknote-v1 instruction (§B3) -> footnote channel + quality delta
Discipline: thinking ON (deepseek echo-mine guardrail — never disabled). LENGTH-GATE FIX (D39.9): a
finish_reason=='length' response is REJECTED/flagged, NOT accepted as valid (exp14 harness bug not
inherited). Every call persists raw usage+cost (D30.10) via the exp15 Spender; per-call gate + $10 hard cap.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.append(str(HERE.parent / "exp15"))
import exp15_llm as LLM
import banknote as BN
BOOK = Path("/home/ubuntu/books/gu-zhenren")
RECORDS = BOOK / "rerun" / "records.json"
OUT = BOOK / "exp16" / "coldstart"
LEDGER = BOOK / "exp16" / "coldstart_costs.jsonl"
COLD_CHAPTERS = [1, 4, 5, 7, 9] # FROZEN (greedy GT-cover: 52/57 entities, 14 chunks)
MODEL = "deepseek-v4-flash"
HARD_CAP = 10.0 # exp16 paid budget (owner 17-18.07), separate from exp15
PER_CALL_CAP = 0.05 # flash is cheap; a single chunk far below this
# ── FROZEN translator prompt: backend/prompts/translator.md (sha256 35d3ad6e...), brief from rerun/book.yaml,
# glossary-injection block OMITTED (cold-start = new book, no bank). ──────────────────────────────
BRIEF = dict(source_lang="zh", target_lang="ru", genre="вебновелла", audience="взрослые читатели вебновелл",
title="蛊真人", honorifics="keep", transcription="palladius", venuti="0.6", footnotes="minimal")
TRANSLATOR_SYSTEM = (
'Ты — профессиональный литературный переводчик с языка «{source_lang}» на язык «{target_lang}».\n'
'Жанр книги: {genre}. Аудитория: {audience}. Книга: «{title}».\n\n'
'Правила перевода (translation brief):\n'
'- Хонорифики: {honorifics}. Транскрипция имён и реалий: {transcription}.\n'
'- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {venuti}.\n'
'- Сноски: {footnotes}.\n'
'- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя.\n'
'- Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи '
'оформляй с нового абзаца через тире «—».\n'
'- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка.\n\n'
'Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков.'
).format(**BRIEF)
TRANSLATOR_USER = "Переведи следующий фрагмент:\n\n{text}"
TRANSLATOR_MD_SHA = "35d3ad6e0656a7d1116fb8b034a4cea087d5c11ac6e043804185f94f15af5829"
def in_deepseek_peak() -> bool:
"""UTC 01:00-04:00 and 06:00-10:00 are the ×2 peak windows (D39.7). new/argless time is banned in
workflow scripts but this is a plain script; use time.gmtime()."""
h = time.gmtime().tm_hour
return (1 <= h < 4) or (6 <= h < 10)
def load_cold_chunks():
recs = json.load(open(RECORDS, encoding="utf-8"))
out = [r for r in recs if r["chapter"] in COLD_CHAPTERS]
out.sort(key=lambda r: (r["chapter"], r["chunk_idx"]))
return out
def gen_one(sp: LLM.Spender, source: str, config: str):
system = TRANSLATOR_SYSTEM + (BN.BANKNOTE_INSTRUCTION if config == "banknote" else "")
user = TRANSLATOR_USER.format(text=source)
msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
in_tok = len(source) + len(system) # rough; gate uses conservative estimate below
ok, pred, why = sp.gate(MODEL, in_tok, LLM.MODELS[MODEL]["max_tokens"])
if not ok:
return {"err": f"gate: {why}", "pred": pred}
r = LLM.call(MODEL, msgs, max_tokens=LLM.MODELS[MODEL]["max_tokens"])
return r
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", choices=["plain", "banknote", "both"], default="both")
ap.add_argument("--force", action="store_true", help="override the DeepSeek peak-window guard")
ap.add_argument("--dry", action="store_true")
a = ap.parse_args()
if in_deepseek_peak() and not a.force:
print(f"REFUSING: UTC hour {time.gmtime().tm_hour} is in a DeepSeek ×2 peak window (01-04/06-10). "
f"Run in the valley or pass --force. (D39.7)")
return
chunks = load_cold_chunks()
print(f"cold-start chunks: {len(chunks)} (chapters {COLD_CHAPTERS})")
OUT.mkdir(parents=True, exist_ok=True)
sp = LLM.Spender(str(LEDGER), per_call_cap=PER_CALL_CAP, hard_cap=HARD_CAP)
print(f"spender: per_call_cap=${PER_CALL_CAP} hard_cap=${HARD_CAP} already_spent=${sp.total:.4f}")
configs = ["plain", "banknote"] if a.config == "both" else [a.config]
stats = {c: {"ok": 0, "length_reject": 0, "err": 0, "banknote_lines": 0, "parse_fail": 0, "truncated": 0}
for c in configs}
for config in configs:
cdir = OUT / config
cdir.mkdir(parents=True, exist_ok=True)
for r in chunks:
cid = f"{r['chapter']}.{r['chunk_idx']}"
raw_fp = cdir / f"{cid}.raw.txt"
if raw_fp.exists():
continue
if a.dry:
print(f" [dry] {config} {cid}")
continue
res = gen_one(sp, r["source"], config)
rec = {"config": config, "id": cid, "model": MODEL, "usage": res.get("usage", {}),
"cost": res.get("cost", 0.0), "finish": res.get("finish"), "err": res.get("err"),
"latency_s": res.get("latency_s")}
# LENGTH-GATE FIX (D39.9): finish=length -> reject, do NOT accept truncated output
if res.get("err"):
stats[config]["err"] += 1
sp.record(rec); print(f" ERR {config} {cid}: {res['err'][:70]}"); continue
if res.get("finish") == "length":
stats[config]["length_reject"] += 1
rec["rejected"] = "finish=length"
sp.record(rec); print(f" LENGTH-REJECT {config} {cid} (comp={res['usage'].get('completion_tokens')})")
continue
sp.record(rec)
text = res["text"]
raw_fp.write_text(text, encoding="utf-8")
if config == "banknote":
clean, block = BN.split_banknote(text)
ents, flags = BN.parse_banknote(block, truncated_generation=False)
(cdir / f"{cid}.clean.txt").write_text(clean, encoding="utf-8")
json.dump({"entries": ents, "flags": flags},
open(cdir / f"{cid}.banknote.json", "w"), ensure_ascii=False, indent=1)
stats[config]["banknote_lines"] += flags["n_banknote_lines"]
stats[config]["parse_fail"] += flags["banknote_parse_fail"]
stats[config]["truncated"] += flags["banknote_truncated"]
stats[config]["ok"] += 1
print(f" ok {config} {cid}: {len(text)}ch finish={res['finish']} ${res['cost']:.5f}")
print(f"\n=== stats: {json.dumps(stats, ensure_ascii=False)} ===")
print(f"=== total spend this run: ${sp.total:.4f} / cap ${HARD_CAP} ===")
if __name__ == "__main__":
main()