151 lines
8.2 KiB
Python
151 lines
8.2 KiB
Python
#!/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()
|