#!/usr/bin/env python3 """exp14 §кривая — размер-чанка ↔ качество ↔ биллинг ↔ ретраи. Пере-нарезка главы при разных бюджетах (жадная упаковка целых абзацев-строк до char-порога) + whole-chapter. Пайплайн draft(flash thinking-ON)→edit(glm-5 дискурс). Снимаем РЕАЛЬНЫЕ in/out/reasoning-токены, латентность, ошибки-по-причинам, output-token-cost, KPI-абзацы. Chapter-level инъекция едина для всех сегментов (изолируем РАЗМЕР, не глоссарий). Бюджет чанка отчитывается в ВЫХОДНЫХ токенах (реальный токенайзер glm-4.6). Оптимизация — retry-adjusted output-cost. Оговорка: у этой вебновеллы плоская структура (1 предл./строка, мало сцен-маркеров) → политика границ «сцена vs жадная» слабо различима; меряем жадную + whole. Использование: exp14_sizecurve.py [--chapters 17,13] [--dry] """ from __future__ import annotations import argparse, json, re, statistics, sys, time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import exp14_common as C import exp14_prompts as P RERUN = Path("/home/ubuntu/books/gu-zhenren/rerun") RECS = {(r["chapter"], r["chunk_idx"]): r for r in json.load(open(RERUN / "records.json"))} INJ = json.load(open(C.DIAG / "injection_blocks.json")) SIZES = [800, 1600, 3200, 99999] # zh-chars; 99999 = whole-chapter OUT = C.DIAG / "sizecurve"; OUT.mkdir(exist_ok=True) CAPS = {"deepseek-v4-flash": 0.03, "glm-5": 0.08} def chapter_source(ch): cks = sorted(c for (cc, c) in RECS if cc == ch) return "\n".join(RECS[(ch, c)]["source"] for c in cks) def rechunk(src, budget): """Жадная упаковка целых строк (абзацев) до budget zh-символов; строку не рвём.""" lines = [l for l in src.split("\n") if l.strip()] segs, cur, n = [], [], 0 for l in lines: if cur and n + len(l) > budget: segs.append("\n".join(cur)); cur, n = [], 0 cur.append(l); n += len(l) if cur: segs.append("\n".join(cur)) return segs def paras(t): return [p for p in re.split(r"\n\s*\n", t.strip()) if p.strip()] def sents(p): return [s for s in re.split(r"(?<=[.!?…])\s+", p.strip()) if s.strip()] def kpi(t): narr = [p for p in paras(t) if not p.strip().startswith("—")] sp = [len(sents(p)) for p in narr] or [0] return round(statistics.mean(sp), 2), round(sum(1 for x in sp if x == 1)/len(sp), 3) def run(): ap = argparse.ArgumentParser(); ap.add_argument("--chapters", default="17,13") ap.add_argument("--dry", action="store_true"); a = ap.parse_args() chapters = [int(x) for x in a.chapters.split(",")] sp = C.Spender(C.DIAG / "sizecurve_costs.jsonl", CAPS) tsys = P.translator_system(); esys = P.editor_system("discourse") rows = [] for ch in chapters: src = chapter_source(ch) einj = INJ.get(f"{ch}/ALL", {}).get("editor_constraint", "") ginj = INJ.get(f"{ch}/ALL", {}).get("bilingual_glossary", "") for budget in SIZES: segs = rechunk(src, budget) label = "whole" if budget == 99999 else f"{budget}c" tag = f"{ch}-{label}" outp = OUT / f"{tag}.txt" if outp.exists(): print(f" [skip] {tag}"); continue print(f"\n=== ch{ch} size={label}: {len(segs)} сегментов ===") if a.dry: for s in segs: print(f" seg {len(s)}zh-chars") continue final_parts, in_tot, out_tot, reason_tot, seg_costs, errs, lat = [], 0, 0, 0, 0.0, [], 0.0 for i, seg in enumerate(segs): # DRAFT (flash, thinking ON) ds = C.spec("deepseek-v4-flash", temp=0.3, max_tokens=8000) dmsgs = C.messages_with_injection(tsys, ginj, P.translator_user(seg)) t0 = time.time() draft, derr, dusage = C.call(ds, dmsgs, timeout=300) sp.record({"model": "deepseek-v4-flash", "keyenv": ds["keyenv"], "tag": tag, "seg": i, "stage": "draft", "err": derr, "usage": dusage}) if derr: errs.append(f"draft:{derr[:30]}"); draft = seg # фолбэк — исходник (эхо-гард поймает) # EDIT (glm-5 discourse) es = C.spec("glm-5", temp=0.4, max_tokens=8000) emsgs = C.messages_with_injection(esys, einj, P.editor_user(seg, draft)) final, eerr, eusage = C.call(es, emsgs, timeout=360) c = sp.record({"model": "glm-5", "keyenv": es["keyenv"], "tag": tag, "seg": i, "stage": "edit", "err": eerr, "usage": eusage}) lat += round(time.time() - t0, 1) if eerr: errs.append(f"edit:{eerr[:30]}"); final = draft final_parts.append(final) in_tot += (dusage.get("prompt_tokens", 0) or 0) + (eusage.get("prompt_tokens", 0) or 0) out_tot += (dusage.get("completion_tokens", 0) or 0) + (eusage.get("completion_tokens", 0) or 0) full = "\n\n".join(final_parts) outp.write_text(full, encoding="utf-8") ru_out_tok = C.count_tokens(full, "glm") mean_spp, share1 = kpi(full) han = sum(1 for ch_ in full if "一" <= ch_ <= "鿿") row = dict(chapter=ch, size=label, n_seg=len(segs), in_tok=in_tot, out_tok=out_tot, ru_out_tok=ru_out_tok, cost=round(sp.by_key.get("ZAI_API_KEY", 0)+sp.by_key.get("DEEPSEEK_API_KEY", 0), 4), mean_spp=mean_spp, share_1sent=share1, han=han, errs=errs, latency_s=round(lat, 1)) rows.append(row) print(f" segs={len(segs)} in={in_tot} out={out_tot} ru_out_tok={ru_out_tok} " f"mean_spp={mean_spp} share1={share1} CJK={han} errs={len(errs)} lat={lat:.0f}s") (OUT / "sizecurve.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") if rows: print("\n=== КРИВАЯ (per chapter×size) ===") print(f"{'ch-size':<12}{'segs':>5}{'out_tok':>9}{'mean_spp':>9}{'share1':>8}{'CJK':>5}{'errs':>5}") for r in rows: print(f"{r['chapter']}-{r['size']:<8}{r['n_seg']:>5}{r['out_tok']:>9}{r['mean_spp']:>9}{r['share_1sent']:>8}{r['han']:>5}{len(r['errs']):>5}") if __name__ == "__main__": run()