codebee 0.1.74 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +628 -607
  2. package/LICENSE +21 -21
  3. package/README.md +507 -508
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -100
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/analytics.py +38 -38
  14. package/app/core/artifacts.py +148 -148
  15. package/app/core/assets.py +110 -110
  16. package/app/core/attachments.py +623 -623
  17. package/app/core/audit.py +30 -30
  18. package/app/core/backup.py +470 -470
  19. package/app/core/beekeeper.py +183 -183
  20. package/app/core/benchstore.py +95 -95
  21. package/app/core/board.py +209 -209
  22. package/app/core/bookmeta.py +1036 -921
  23. package/app/core/bookmeta_catalog.py +180 -180
  24. package/app/core/branching.py +137 -137
  25. package/app/core/builtin_agent.py +1680 -1680
  26. package/app/core/capability.py +144 -144
  27. package/app/core/chaptersafe.py +32 -32
  28. package/app/core/cleanup.py +303 -303
  29. package/app/core/compaction.py +201 -201
  30. package/app/core/contracts.py +255 -255
  31. package/app/core/covergen.py +356 -356
  32. package/app/core/defectretro.py +43 -43
  33. package/app/core/diagnostics.py +114 -114
  34. package/app/core/dispatch.py +190 -190
  35. package/app/core/dispatch_log.py +128 -128
  36. package/app/core/env_scrub.py +85 -85
  37. package/app/core/errorlog.py +137 -137
  38. package/app/core/evaluation.py +174 -174
  39. package/app/core/failure_notify.py +290 -290
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +389 -387
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +22 -0
  45. package/app/core/manager.py +2232 -2232
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +189 -189
  49. package/app/core/openapi.py +154 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +189 -18
  53. package/app/core/planner.py +88 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/browser.py +655 -655
  60. package/app/core/publish/fanqie.py +165 -165
  61. package/app/core/publish/flow.py +640 -640
  62. package/app/core/publish/flows-fanqie-calibrated.json +336 -336
  63. package/app/core/publish/manager.py +1118 -1118
  64. package/app/core/publish/qimao.py +176 -176
  65. package/app/core/publish/ws.py +139 -139
  66. package/app/core/quality_gate.py +194 -194
  67. package/app/core/redact.py +38 -38
  68. package/app/core/repeat_guard.py +129 -129
  69. package/app/core/retrieval.py +137 -137
  70. package/app/core/router.py +398 -398
  71. package/app/core/runner.py +2238 -2225
  72. package/app/core/selfupdate.py +384 -384
  73. package/app/core/session_log.py +161 -161
  74. package/app/core/sessions.py +312 -312
  75. package/app/core/settings_schema.py +270 -270
  76. package/app/core/share_page.py +114 -114
  77. package/app/core/skill_scan.py +139 -139
  78. package/app/core/skillpacks/market/character-bible.md +66 -66
  79. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  80. package/app/core/skillpacks/market/git-workflow.md +57 -57
  81. package/app/core/skillpacks/market/release-notes.md +72 -72
  82. package/app/core/skillpacks/market/weekly-report.md +71 -71
  83. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  84. package/app/core/skills.py +1 -1
  85. package/app/core/step_runner.py +132 -132
  86. package/app/core/story_tracking.py +275 -241
  87. package/app/core/task_compile.py +199 -199
  88. package/app/core/telemetry.py +292 -292
  89. package/app/core/tlsctx.py +64 -64
  90. package/app/core/token_meter.py +147 -147
  91. package/app/core/tracing.py +144 -144
  92. package/app/core/upstream.py +41 -41
  93. package/app/core/usage.py +1035 -1035
  94. package/app/core/volumes.py +389 -389
  95. package/app/core/wxdigest.py +858 -858
  96. package/app/core/zentao.py +2692 -2692
  97. package/app/main.py +59 -0
  98. package/app/mcp_server.py +244 -244
  99. package/app/output/_createbook_body.json +1 -0
  100. package/app/output/_probe_fq_click.py +50 -0
  101. package/app/output/_probe_fq_form.py +42 -0
  102. package/app/output/_probe_fq_modal.py +55 -0
  103. package/app/output/_probe_fq_points.py +71 -0
  104. package/app/output/_probe_fq_submit.py +34 -0
  105. package/app/output/_probe_fq_watch.py +62 -0
  106. package/app/pick_dialog.py +110 -110
  107. package/app/ui/app.js +108 -12
  108. package/app/ui/artdiff.js +74 -74
  109. package/app/ui/board.html +162 -162
  110. package/app/ui/board.js +305 -305
  111. package/app/ui/i18n.js +15 -0
  112. package/app/ui/icons/bee.svg +79 -79
  113. package/app/ui/index.html +4 -0
  114. package/app/ui/manifest.json +16 -16
  115. package/app/ui/qrcode.js +2297 -2297
  116. package/app/ui/style.css +7086 -7070
  117. package/bin/tutti.js +147 -147
  118. package/package.json +48 -48
  119. package/tools/wx_reader.py +261 -261
@@ -2521,6 +2521,20 @@ def _ensure_critique_placeholders(tpl):
2521
2521
 
2522
2522
  # ---------------------------------------------------------------- 连载引擎(长篇小说:逐章打磨)
2523
2523
 
2524
+ def serial_locked_chapter_block(workdir, chapter):
2525
+ """本章锁定章纲注入块(起草/修订共用):工作目录章纲/*ch-<N>.md 存在时
2526
+ 以最高优先级随提示词下发——批次大纲可能被继承链带偏,锁定章纲才是作者
2527
+ 逐章确认过的事实标准(2026-10-03 不周山 rogue 大纲实案的治本闸)。
2528
+ 无章纲返回 ""(老书零噪音)。"""
2529
+ text = planner.locked_chapter_outline_text(workdir, chapter)
2530
+ if not text:
2531
+ return ""
2532
+ return ("## 锁定章纲(作者已确认,最高优先级)\n"
2533
+ "本章标题、视角(POV)、时间地点、事件节拍与章末落点必须逐项对齐下方章纲;"
2534
+ "若与其他要点冲突,以锁定章纲为准;其后章目的伏笔揭示位不得提前烧毁。\n\n"
2535
+ + text)
2536
+
2537
+
2524
2538
  SERIAL_CHAPTER_PROMPT = """你是一名网文作者(写作规范见下方经验库)。本书信息如下,请先完整读完再执行末尾的「本章任务」。
2525
2539
 
2526
2540
  __SKILLS__
@@ -2539,6 +2553,7 @@ __VOLUME__
2539
2553
  - 剧情要点:__BEATS__
2540
2554
  - 章末钩子:__HOOK__
2541
2555
  - 本章爽点/情绪爆点:__HIGHLIGHT__(必须按特写镜头写厚:铺垫在前、放大在中、余波在后,用动作/五感/生理反应/环境反馈呈现,禁止一句话带过)
2556
+ __LOCKED_CH__
2542
2557
  - 正文约 __WORDS__ 字,中文,直接开写正文(可含本章标题行)。
2543
2558
 
2544
2559
  ## 前情提要(此前各章结尾摘录,衔接用)
@@ -2565,6 +2580,7 @@ __VOLUME__
2565
2580
  ## 本章按大纲应完成
2566
2581
  __BEATS__
2567
2582
  (本章爽点/情绪爆点:__HIGHLIGHT__)
2583
+ __LOCKED_CH__
2568
2584
 
2569
2585
  ## 本章评审意见
2570
2586
  __CRITIQUE__
@@ -2832,6 +2848,97 @@ def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
2832
2848
  return cand[:limit]
2833
2849
 
2834
2850
 
2851
+ _ARCHIVE_DIR = "已成稿"
2852
+ _CH_MENTION_RE = re.compile(r"第\s*(\d{1,4})\s*(?:[—–\-~至到]\s*(\d{1,4})\s*)?章")
2853
+
2854
+
2855
+ def _archived_preface(workdir, start):
2856
+ """前文回退:续写批次的前作章文件被归档成单文件(发布后 chapter-NN.md
2857
+ 不在场)时,从「已成稿/」目录找回前文全文,供全书评审与合并成书使用。
2858
+ 任一前文章节文件仍在场则视为未归档,返回空串(正常路径零影响)。"""
2859
+ if start <= 1:
2860
+ return ""
2861
+ for i in range(1, start):
2862
+ if _read_chapter(workdir, i):
2863
+ return ""
2864
+ adir = os.path.join(str(workdir), _ARCHIVE_DIR)
2865
+ try:
2866
+ names = sorted(os.listdir(adir))
2867
+ except OSError:
2868
+ return ""
2869
+ parts = []
2870
+ for name in names:
2871
+ if not name.lower().endswith((".md", ".txt")):
2872
+ continue
2873
+ if "目录" in name: # 阅读目录/章节目录是索引不是正文
2874
+ continue
2875
+ p = os.path.join(adir, name)
2876
+ if not os.path.isfile(p):
2877
+ continue
2878
+ try:
2879
+ t = _read_text_any_enc(p)
2880
+ except Exception:
2881
+ continue
2882
+ if t and t.strip():
2883
+ parts.append(t.strip())
2884
+ return "\n\n".join(parts)
2885
+
2886
+
2887
+ def _full_manuscript(workdir, start, end, cap=60000):
2888
+ """全书评审文本:本批 start..end 全文保底在场,前文(章文件缺失时回退
2889
+ 归档)用剩余预算从尾部截取——越靠近本批的前文对一致性越要紧。直拼再
2890
+ [:cap] 会把预算整段喂给前文、把本批(真正的评审对象)截掉。"""
2891
+ new_text = "\n\n".join(t for t in (_read_chapter(workdir, i)
2892
+ for i in range(start, end + 1)) if t)
2893
+ old_text = "\n\n".join(t for t in (_read_chapter(workdir, i)
2894
+ for i in range(1, start)) if t)
2895
+ if not old_text and start > 1:
2896
+ old_text = _archived_preface(workdir, start)
2897
+ budget = cap - len(new_text) - 2
2898
+ if budget <= 0:
2899
+ return new_text[:cap]
2900
+ if len(old_text) > budget:
2901
+ cut_mark = "……(前文较长,仅保留紧邻本批的尾部)……\n\n"
2902
+ keep = budget - len(cut_mark)
2903
+ old_text = cut_mark + (old_text[-keep:] if keep > 0 else "")
2904
+ return (old_text + "\n\n" + new_text)[:cap] if old_text else new_text[:cap]
2905
+
2906
+
2907
+ def _chapters_named_in(issues, lo=1, hi=None):
2908
+ """评审 issue 文本里点名的章号集合(「第22章」「第26—28章」都认)。
2909
+ 用于把全局一致性 major 落到具体章;范围外的章号丢弃。"""
2910
+ out = set()
2911
+ for it in issues or []:
2912
+ if not isinstance(it, dict):
2913
+ continue
2914
+ for m in _CH_MENTION_RE.finditer(str(it.get("note") or it.get("issue") or "")):
2915
+ a = int(m.group(1))
2916
+ b = int(m.group(2)) if m.group(2) else a
2917
+ if b < a:
2918
+ a, b = b, a
2919
+ for c in range(a, b + 1):
2920
+ if c >= lo and (hi is None or c <= hi):
2921
+ out.add(c)
2922
+ return out
2923
+
2924
+
2925
+ def _dedup_issues(items, key_len=60):
2926
+ """跨轮评审 issues 去重:同章同维度同要点(归一化前缀)只保留首条。
2927
+ 全局评审每轮重跑都会把上一轮的问题原样或换个说法再列一遍,不去重会把
2928
+ 同一处问题计成三四条 major,门禁报告数字虚高。"""
2929
+ seen, out = set(), []
2930
+ for x in items or []:
2931
+ if not isinstance(x, dict):
2932
+ continue
2933
+ k = (str(x.get("chapter")), str(x.get("dim")),
2934
+ re.sub(r"\s", "", str(x.get("note") or ""))[:key_len])
2935
+ if k in seen:
2936
+ continue
2937
+ seen.add(k)
2938
+ out.append(x)
2939
+ return out
2940
+
2941
+
2835
2942
  def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
2836
2943
  """连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
2837
2944
  import json as _json
@@ -3410,6 +3517,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3410
3517
  .replace("__TITLE__", ch["title"])
3411
3518
  .replace("__BEATS__", branch_beats or ch["beats"] or "按大纲推进")
3412
3519
  .replace("__HOOK__", branch_hook or ch.get("hook") or "留下悬念")
3520
+ .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
3413
3521
  .replace("__HIGHLIGHT__", ch.get("highlight") or "按剧情要点自然铺设一处小冲突/小反转")
3414
3522
  .replace("__STYLE__", style_tpl)
3415
3523
  .replace("__QUALITY_GATE__", novel_quality.opening_requirements(i, wpc))
@@ -3804,6 +3912,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3804
3912
  .replace("__GOAL__", task["goal"])
3805
3913
  .replace("__VOLUME__", vol_block_for(i))
3806
3914
  .replace("__BEATS__", ch.get("beats") or "按大纲推进")
3915
+ .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
3807
3916
  .replace("__HIGHLIGHT__", ch.get("highlight")
3808
3917
  or "按剧情要点自然铺设一处小冲突/小反转")
3809
3918
  .replace("__STYLE__", style_tpl)
@@ -3881,6 +3990,16 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3881
3990
  store.update_run(run_id, story_tracking_warning=str(exc)[:300])
3882
3991
  except Exception:
3883
3992
  pass
3993
+ # 旧账换新账(章内修订回路):本章终评达标 → 用终轮评审的 issues 换掉
3994
+ # 第 1 轮的旧账。不换的话「修订后已达标」的章仍顶着第 1 轮 majors,
3995
+ # 最终 publishable 被陈账永远压着 False。放在 story_tracking 取材之后:
3996
+ # 事实沉淀仍吃得到修订前的完整意见,门禁只认终轮评判。
3997
+ if cs_new["passed"] and cj_by_agent:
3998
+ issues_all[:] = [x for x in issues_all if x.get("chapter") != i]
3999
+ for _cj in cj_by_agent.values():
4000
+ if isinstance(_cj, dict):
4001
+ issues_all.extend({"chapter": i, **it}
4002
+ for it in (_cj.get("issues") or [])[:6])
3884
4003
  # 资源账本(借鉴角色资源账本):评审提出的道具/伤情/承诺/伏笔增量
3885
4004
  # 追加到 .codebee/resource-ledger.md,下一章起草时注入,防跨章穿帮。
3886
4005
  # 失败静默——账本是增强不是硬依赖。
@@ -3948,6 +4067,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3948
4067
  .replace("__GOAL__", task["goal"])
3949
4068
  .replace("__VOLUME__", vol_block_for(j))
3950
4069
  .replace("__BEATS__", _wch.get("beats") or "按大纲推进")
4070
+ .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, j))
3951
4071
  .replace("__HIGHLIGHT__", _wch.get("highlight")
3952
4072
  or "按剧情要点自然铺设一处小冲突/小反转")
3953
4073
  .replace("__STYLE__", style_tpl)
@@ -3973,16 +4093,17 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3973
4093
  store.update_run(run_id, signing_checkpoint=cp)
3974
4094
 
3975
4095
  # ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
3976
- full_text = "\n\n".join(_read_chapter(workdir, i) for i in range(1, end + 1))
4096
+ full_text = _full_manuscript(workdir, start, end)
3977
4097
  global_issues = []
3978
4098
  global_review_results = []
3979
4099
 
3980
4100
  def run_global_round(agent_list):
3981
- """一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。
4101
+ """一轮全局评审:返回 (出分评审数, 按维累计分, 有效评审, 本轮 issues)。
4102
+ 失败/不可解析不得当成低分计入。
3982
4103
 
3983
4104
  多评审并发(2026-09-22,同章级评审并发):各自读同一份全书文本、互不依赖,
3984
4105
  串行只是把等待时间叠起来。共享结构仍在 join 后按原顺序合并,结果与串行一致。"""
3985
- gmeans_acc, scored, valid_reviews = {}, 0, []
4106
+ gmeans_acc, scored, valid_reviews, round_issues = {}, 0, [], []
3986
4107
  results = {}
3987
4108
  # 绑定解析提前到主线程(线程内不做会写盘的 bind_agent)
3988
4109
  bound = [modelhub.bind_agent(a, difficulty) for a in agent_list]
@@ -4038,14 +4159,15 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4038
4159
  if not errors:
4039
4160
  valid_reviews.append({"id": agent_list[idx].get("id") or "reviewer-%d" % idx,
4040
4161
  "scores": scores})
4041
- global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
4162
+ round_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
4042
4163
  for d in dims:
4043
4164
  v = scores.get(d) if not errors else partial_scores.get(d)
4044
4165
  if v is not None:
4045
4166
  gmeans_acc.setdefault(d, []).append(float(v))
4046
- return scored, gmeans_acc, valid_reviews
4167
+ return scored, gmeans_acc, valid_reviews, round_issues
4047
4168
 
4048
- gscored, gmeans_acc, global_review_results = run_global_round(critics)
4169
+ gscored, gmeans_acc, global_review_results, _gi = run_global_round(critics)
4170
+ global_issues.extend(_gi)
4049
4171
  # 「评不上」≠「评了低分」:全局评审全挂时先从其它真实智能体补位(对齐章级
4050
4172
  # 评审者级 fallback);补位后仍零分则判 run 失败——「无法评审」绝不能当成
4051
4173
  # 「全局评审未通过」去盖「未达标」章(2026-09-18 假未达标案:codex 绑定链
@@ -4055,8 +4177,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4055
4177
  tried = {a.get("id") for a in critics}
4056
4178
  for spare in [a for a in (agents or [])
4057
4179
  if a.get("mode") == "real" and a.get("id") not in tried][:2]:
4058
- sc, acc, spare_reviews = run_global_round([spare])
4180
+ sc, acc, spare_reviews, _sgi = run_global_round([spare])
4059
4181
  global_review_results.extend(spare_reviews)
4182
+ global_issues.extend(_sgi)
4060
4183
  for d, xs in acc.items():
4061
4184
  gmeans_acc.setdefault(d, []).extend(xs)
4062
4185
  gscored += sc
@@ -4083,9 +4206,17 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4083
4206
  while (not global_pass) and polish_rounds < 2 and chapter_scores and global_means:
4084
4207
  polish_rounds += 1
4085
4208
  weak = _weakest_chapters(chapter_scores, global_means, threshold, limit=2)
4209
+ # 全局一致性 major 点名的章优先进入打磨面:脱纲/主线漂移类问题的病灶章
4210
+ # 单章分数往往全部达标(章级评审只看局部),只按分数选章永远选不中
4211
+ # (2026-10-03 不周山守村人实案:21–28 章整体脱纲、章分全 8+,两轮
4212
+ # 打磨重改的全是达标章,全局分纹丝不动)。
4213
+ named = _chapters_named_in(global_issues, lo=start, hi=end)
4214
+ if named:
4215
+ weak = ([c for c in chapter_scores if c.get("chapter") in named]
4216
+ + [c for c in weak if c.get("chapter") not in named])[:2]
4086
4217
  if not weak:
4087
4218
  break
4088
- note = "自动打磨第 %d 轮:全局评审未过,重改最弱章 %s" % (
4219
+ note = "自动打磨第 %d 轮:全局评审未过,重改 %s" % (
4089
4220
  polish_rounds, "、".join("第 %d 章" % c["chapter"] for c in weak))
4090
4221
  pstep, _ = store.add_step(run_id, "polish-r%d" % polish_rounds, impl["id"],
4091
4222
  impl.get("label"), note=note)
@@ -4098,10 +4229,24 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4098
4229
  for d in dims if float((c.get("means") or {}).get(d, 0.0)) < threshold)
4099
4230
  gj = ";".join("%s %.1f" % (d, s2) for d, s2 in global_means.items()
4100
4231
  if float(s2) < threshold)
4101
- crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
4102
- "\n- 重改要求:优先修全书节奏/衔接问题(章间过渡、信息倾泻、主角主动性),"
4103
- "再补本章短板;不得改动既有剧情主线的关键事实。"
4104
- % (dims_txt or "(无)", threshold, gj or "(无)"))
4232
+ # 本章被全局评审点名时附上原话,并放开「不得改主线」约束——脱纲章
4233
+ # 唯一的修法就是按大纲推翻自创情节,保事实微调恰恰修不动
4234
+ ch_named = [x for x in global_issues
4235
+ if i in _chapters_named_in([x], lo=i, hi=i)]
4236
+ if ch_named:
4237
+ crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
4238
+ "\n- 全书评审对本章的点名(逐条对照处理):\n%s"
4239
+ "\n- 重改要求:若现稿剧情与「本章按大纲应完成」脱轨(评审已点名),"
4240
+ "以本章大纲要点为准重写剧情,可推翻现稿自创的情节线;"
4241
+ "本书其余各章的既成事实仍不得改动。"
4242
+ % (dims_txt or "(无)", threshold, gj or "(无)",
4243
+ "\n".join("- [%s] %s" % (x.get("dim"), str(x.get("note") or "")[:220])
4244
+ for x in ch_named[:4])))
4245
+ else:
4246
+ crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
4247
+ "\n- 重改要求:优先修全书节奏/衔接问题(章间过渡、信息倾泻、主角主动性),"
4248
+ "再补本章短板;不得改动既有剧情主线的关键事实。"
4249
+ % (dims_txt or "(无)", threshold, gj or "(无)"))
4105
4250
  _psig = novel_quality.signal_summary(_read_chapter(workdir, i), chapter=i)
4106
4251
  if _psig.get("hints"):
4107
4252
  crit += ("\n- 确定性复查提示(修订须对应处理):"
@@ -4114,7 +4259,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4114
4259
  .replace("__I__", str(i)).replace("__FILE__", "chapter-%02d.md" % i)
4115
4260
  .replace("__GOAL__", task["goal"])
4116
4261
  .replace("__VOLUME__", vol_block_for(i))
4262
+ .replace("__BEATS__", ch.get("beats") or "按大纲推进")
4263
+ .replace("__LOCKED_CH__", serial_locked_chapter_block(workdir, i))
4264
+ .replace("__HIGHLIGHT__", ch.get("highlight")
4265
+ or "按剧情要点自然铺设一处小冲突/小反转")
4117
4266
  .replace("__CRITIQUE__", crit)
4267
+ .replace("__STYLE__", style_tpl)
4118
4268
  .replace("__WORDS__", str(wpc)))
4119
4269
  prompt = attachments.append_task_context(prompt, task)
4120
4270
  res = _run_step(run_id, "polish-c%d" % i, modelhub.bind_agent(impl, difficulty),
@@ -4158,6 +4308,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4158
4308
  c2["polished"] = True
4159
4309
  c2["words"] = _wc(_read_chapter(workdir, i))
4160
4310
  fixed.append(i)
4311
+ if repolished:
4312
+ # 旧账换新账(章级):该章旧 issues 是对打磨前稿件的评判,重写
4313
+ # 已落盘并重评,陈账不撤会同全局陈账一起永远压着 publishable
4314
+ issues_all[:] = [x for x in issues_all if x.get("chapter") != i]
4315
+ for _cj in cj_by_agent.values():
4316
+ if isinstance(_cj, dict):
4317
+ issues_all.extend({"chapter": i, **it}
4318
+ for it in (_cj.get("issues") or [])[:6])
4161
4319
  store.update_run(run_id, chapter_scores=chapter_scores)
4162
4320
  _check_cancel(ev)
4163
4321
  if not fixed:
@@ -4173,8 +4331,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4173
4331
  # 重评全书一致性(同一评审闭包;本轮全挂则保留上一轮结论——评审链挂了
4174
4332
  # 不代表书变差,不能拿「无法评审」覆盖真实分数)
4175
4333
  try:
4176
- full_text = "\n\n".join(_read_chapter(workdir, i2) for i2 in range(1, end + 1))
4177
- gscored2, gmeans_acc2, global_review_results2 = run_global_round(critics)
4334
+ full_text = _full_manuscript(workdir, start, end)
4335
+ gscored2, gmeans_acc2, global_review_results2, gi2 = run_global_round(critics)
4178
4336
  except BaseException:
4179
4337
  store.finish_step(run_id, pstep["n"], "failed",
4180
4338
  summary="打磨后全书重评异常中止,已落盘章稿不受影响")
@@ -4185,6 +4343,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4185
4343
  global_pass = _all_ge(global_means, threshold)
4186
4344
  global_review = quality_gate.aggregate_reviews(
4187
4345
  global_review_results, dims, threshold=threshold)
4346
+ # 旧账换新账:上一轮的 majors 是对打磨前稿件的评判,重评出了新分
4347
+ # 却不换 issues,重写修好了也永远 publishable=False(陈账死闸)
4348
+ global_issues[:] = gi2
4188
4349
  store.finish_step(run_id, pstep["n"], "done" if global_pass else "failed",
4189
4350
  summary="重改 %s;打磨后全局 %s(%s)" % (
4190
4351
  "、".join("第 %d 章" % x for x in fixed),
@@ -4197,6 +4358,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4197
4358
  ms_name = _ms_name(task.get("manuscript"))
4198
4359
  book_title = outline.get("book_title") or task["title"]
4199
4360
  parts = ["# %s" % book_title, ""]
4361
+ # 前文归档回退:前作章文件已被归档(chapter-NN.md 不在场)时,合并成品
4362
+ # 不能只剩本批——否则续写批次的「成书」会静默丢掉此前全部章节
4363
+ arch_preface = _archived_preface(workdir, start) if start > 1 else ""
4364
+ if arch_preface:
4365
+ parts.append("## 前文(第 1–%d 章 · 归档稿)" % (start - 1))
4366
+ parts.append("")
4367
+ parts.append(arch_preface)
4368
+ parts.append("")
4200
4369
  # 分卷标题:卷首章之前插「第 X 卷 《卷名》」分隔(全书 1..end 都插,续写
4201
4370
  # 批次合并时上一批的卷标题也一并补上,不会只在首批出现)
4202
4371
  vol_heads = {}
@@ -4217,13 +4386,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4217
4386
  f.write("\n".join(parts))
4218
4387
  total_words = _wc("\n".join(parts))
4219
4388
  store.finish_step(run_id, step["n"], "done",
4220
- summary="已合并 %d 章为 %s(约 %d 字%s)" % (
4389
+ summary="已合并 %d 章为 %s(约 %d 字%s%s)" % (
4221
4390
  n, ms_name, total_words,
4222
- ",分 %d 卷" % len(vol_heads) if vol_heads else ""),
4391
+ ",分 %d 卷" % len(vol_heads) if vol_heads else "",
4392
+ ",前文 1–%d 章取自归档" % (start - 1) if arch_preface else ""),
4223
4393
  duration_s=0.1)
4224
4394
 
4225
4395
  chapters_pass = all(c["passed"] for c in chapter_scores)
4226
- major_issues = [x for x in issues_all + global_issues
4396
+ major_issues = [x for x in _dedup_issues(list(issues_all) + list(global_issues))
4227
4397
  if isinstance(x, dict) and x.get("severity") == "major"]
4228
4398
  publishable = bool(chapters_pass and global_pass and not major_issues)
4229
4399
  signing_checkpoint = (store.get_run(run_id) or {}).get("signing_checkpoint") or {}
@@ -4288,7 +4458,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4288
4458
  lines += ["", "## 全局评审(阈值 %.1f)" % threshold, ""]
4289
4459
  lines += ["- %s:%.1f" % (d, global_means.get(d, 0.0)) for d in dims]
4290
4460
  lines += ["", "## 主要问题", ""]
4291
- majors = [x for x in issues_all + global_issues if x.get("severity") == "major"][:12]
4461
+ majors = [x for x in _dedup_issues(list(issues_all) + list(global_issues))
4462
+ if x.get("severity") == "major"][:12]
4292
4463
  if majors:
4293
4464
  lines.extend("- [第%s章][%s] %s" % (str(x.get("chapter", "?")), x.get("dim", "?"),
4294
4465
  str(x.get("note", ""))[:150]) for x in majors)
@@ -11,6 +11,7 @@ import json
11
11
  import os
12
12
  import re
13
13
  import time
14
+ from pathlib import Path
14
15
 
15
16
  from . import knowledge, modelhub, runner, skills, usage, volumes
16
17
 
@@ -283,6 +284,8 @@ __SKILLS__
283
284
  硬性要求:
284
285
  - 第 1 章直接衔接前文(见下方前情),不得跳线、不得重启设定、不得复述前文;
285
286
  - 主线沿既有脉络推进,新冲突尽量从已埋伏笔中生长,人物性格与前文一致;
287
+ - 若下方给出「锁定章纲」:本批各章 title/beats/hook 必须逐章从对应章纲提炼对齐
288
+ (标题沿用、视角不换、节拍不裁并、伏笔揭示位不提前),不得虚构章纲之外的主线事件;
286
289
  - 每章都写清“主角主动选择 → 阻碍 → 局面变化 → 章末钩子”,禁止水字数的日常流水账;
287
290
  - 复杂情感必须落到触发事件、动作/生理反应和潜台词,不能用一句概括代替;
288
291
  - 延续既定叙述视角和语言锚点(见下方既定文风锚),删掉重复复述,避免节奏变慢;
@@ -295,6 +298,9 @@ __STYLE_ANCHOR__
295
298
  ## 前情大纲(已完成章节,章号为全书章号)
296
299
  __PREV_OUTLINE__
297
300
 
301
+ ## 锁定章纲(本批各章已确认的章纲,最高优先级;为空则按前情自然规划)
302
+ __LOCKED_OUTLINE__
303
+
298
304
  ## 最新一章结尾(衔接锚点)
299
305
  __PREV_TAIL__
300
306
 
@@ -480,6 +486,86 @@ def book_volume_plan(task, outline, upto):
480
486
  return volumes.merge_titles(plan, named)
481
487
 
482
488
 
489
+ _CH_OUTLINE_FILE_RE = re.compile(r"ch-(\d{1,4})\.md$", re.IGNORECASE)
490
+
491
+
492
+ def _read_workdir_text(p, cap):
493
+ """章纲/卷纲文件读文本:UTF-8 → GBK → replace(同 _prev_serial_story 纪律,
494
+ CLI 子代理在中文 Windows 上可能把文件落成 GBK)。"""
495
+ try:
496
+ b = p.read_bytes()
497
+ except OSError:
498
+ return ""
499
+ for enc in ("utf-8", "gbk"):
500
+ try:
501
+ return b.decode(enc)[:cap].strip()
502
+ except UnicodeDecodeError:
503
+ continue
504
+ return b.decode("utf-8", "replace")[:cap].strip()
505
+
506
+
507
+ def locked_chapter_outline_text(workdir, chapter, cap=3000):
508
+ """工作目录里本章的锁定章纲全文(章纲/*ch-<N>.md,按文件名章号精确匹配)。
509
+ 逐章章纲是作者逐章确认过的事实标准——大纲生成、起草、修订三面都应以它为
510
+ 最高优先级,否则「起草跟批次大纲、评审跟章纲」会永久互斥(2026-10-03
511
+ 不周山 21-28 章 rogue 大纲实案)。无匹配文件返回 ""(老书零噪音)。"""
512
+ if not workdir or chapter is None:
513
+ return ""
514
+ try:
515
+ base = Path(str(workdir)) / "章纲"
516
+ if not base.is_dir():
517
+ return ""
518
+ hits = []
519
+ for p in base.iterdir():
520
+ if not p.is_file():
521
+ continue
522
+ m = _CH_OUTLINE_FILE_RE.search(p.name)
523
+ if m and int(m.group(1)) == int(chapter):
524
+ hits.append(p)
525
+ except OSError:
526
+ return ""
527
+ if not hits:
528
+ return ""
529
+ hits.sort(key=lambda p: p.name) # 同章号多份时取文件名序最大(卷号更高者)
530
+ return _read_workdir_text(hits[-1], cap)
531
+
532
+
533
+ def locked_serial_outline_block(task, start, end, per_cap=2500, total_cap=16000):
534
+ """续写大纲生成的锁定章纲注入块:本批 [start, end] 逐章汇总章纲全文。
535
+ 一份章纲都没有时回退 大纲/*.md(截断兜底,至少锚住卷纲方向);两者皆无
536
+ 返回 ""(零噪音)。"""
537
+ workdir = str(task.get("workdir") or "")
538
+ parts, used = [], 0
539
+ if workdir:
540
+ for n in range(int(start), int(end) + 1):
541
+ if used >= total_cap:
542
+ break
543
+ t = locked_chapter_outline_text(workdir, n, cap=per_cap)
544
+ if t:
545
+ head = "### 第 %d 章锁定章纲" % n
546
+ parts.append(head + "\n" + t)
547
+ used += len(head) + len(t) + 2
548
+ if parts:
549
+ return "\n\n".join(parts)[:total_cap]
550
+ if workdir:
551
+ try:
552
+ base = Path(workdir) / "大纲"
553
+ if base.is_dir():
554
+ buf, used = [], 0
555
+ for p in sorted(base.glob("*.md")):
556
+ if used >= total_cap:
557
+ break
558
+ t = _read_workdir_text(p, min(4000, total_cap - used))
559
+ if t:
560
+ buf.append("### %s\n%s" % (p.name, t))
561
+ used += len(t) + len(p.name) + 8
562
+ if buf:
563
+ return "\n\n".join(buf)[:total_cap]
564
+ except OSError:
565
+ pass
566
+ return ""
567
+
568
+
483
569
  def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None,
484
570
  deadline=None, run_id=None):
485
571
  """连载大纲:编排者 API 优先 → 作者 CLI → 模板。返回 {book_title, chapters:[{title,beats,hook}]}。
@@ -520,6 +606,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
520
606
  else:
521
607
  vol_block, vol_key = "", ""
522
608
  if start > 1:
609
+ locked_block = locked_serial_outline_block(task, start, start + n - 1)
523
610
  prompt = (SERIAL_CONTINUE_OUTLINE_PROMPT
524
611
  .replace("__SKILLS__", sk_block)
525
612
  .replace("__DONE__", str(done))
@@ -532,6 +619,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
532
619
  or "(前文未记录文风锚,请依据前情大纲与最新一章结尾自行归纳,"
533
620
  "归纳后全批保持一致)")
534
621
  .replace("__PREV_OUTLINE__", prev_lines or "(无大纲记录,请依据下方最新一章结尾与小说目标衔接)")
622
+ .replace("__LOCKED_OUTLINE__", locked_block or "(无)")
535
623
  .replace("__PREV_TAIL__", prev_tail or "(无)")
536
624
  .replace("__GOAL__", task["goal"])
537
625
  .replace("__CONTEXT__", task.get("context") or "(无)"))