#!/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()