codebee 0.1.47 → 0.1.49

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.
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, attachments, catalog, dispatch_log, history, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage
22
+ from . import aiflavor, attachments, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -1539,6 +1539,14 @@ def _run_direct(run, task, agents, ev, stats, mode):
1539
1539
  # drain 的路径消息永远不消费,只看非空会空转到轮数上限。
1540
1540
  # 比较步骤前后的未消费数即可区分。
1541
1541
  before_n = len(_pending_messages(run_id))
1542
+ # 钩子注入(message_submit / task_start 通道):一次性消费拼提示词头
1543
+ hook_head = ""
1544
+ try:
1545
+ hook_head = store.pop_run_inject(run_id)
1546
+ except Exception:
1547
+ pass
1548
+ if hook_head:
1549
+ prompt = ("## 项目钩子注入\n" + hook_head + "\n\n" + prompt)
1542
1550
  if bi is not None:
1543
1551
  res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
1544
1552
  step_wd, ev=ev, note=note, images=images,
@@ -1799,6 +1807,13 @@ def _plot_modules(workdir):
1799
1807
  "鼓励化用,不要照抄原句)\n\n" + txt)
1800
1808
 
1801
1809
 
1810
+ def _book_volume_plan(task, outline, upto):
1811
+ """本书的卷规划表(卷边界+卷名+卷弧光)。实现见 planner.book_volume_plan
1812
+ ——放在 planner 是因为它要沿 serial.continues 链读历史 run 的卷名,属
1813
+ 「大纲/命名」职责;此处保留薄封装便于连载流程阅读。"""
1814
+ return planner.book_volume_plan(task, outline, upto)
1815
+
1816
+
1802
1817
  LEDGER_FILE = os.path.join(".codebee", "resource-ledger.md")
1803
1818
  _LEDGER_MAX_CHARS = 6000 # 账本注入上限:太老的状态让评审官收敛 recent 优先
1804
1819
 
@@ -1913,6 +1928,7 @@ __OUTLINE__
1913
1928
 
1914
1929
  ---
1915
1930
  ## 本章任务(执行这一条即可)
1931
+ __VOLUME__
1916
1932
  - 撰写本书第 __I__ 章,把本章正文写入文件 `__FILE__`(直接写入该文件,只写本章)。文件必须以 UTF-8 编码保存:PowerShell 一律显式加 `-Encoding UTF8`(如 `Set-Content -Path __FILE__ -Encoding UTF8`),禁止依赖系统默认编码,否则中文会乱码。
1917
1933
  - 章节标题:__TITLE__
1918
1934
  - 剧情要点:__BEATS__
@@ -1932,6 +1948,8 @@ SERIAL_REVISE_PROMPT = """你是一名网文作者。第 __I__ 章没有通过
1932
1948
  ## 全书目标
1933
1949
  __GOAL__
1934
1950
 
1951
+ ## 本卷上下文
1952
+ __VOLUME__
1935
1953
  ## 本章评审意见
1936
1954
  __CRITIQUE__
1937
1955
 
@@ -2067,6 +2085,20 @@ def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
2067
2085
  return cand[:limit]
2068
2086
 
2069
2087
 
2088
+ def _inject_detection(tpl, text, task):
2089
+ """把确定性检测行(AI 味/叙事架构/节奏)插进评审提示词。
2090
+
2091
+ 命中才插,位置固定为「## 待评审稿件」之前——与技能包/圣经/知识库注入
2092
+ 同一位置,保持前缀稳定(§07 T1.2' 前缀缓存)。单稿件评审与连载逐章
2093
+ 评审共用本函数:此前连载逐章完全没有这条参考线(旗舰场景漏挂)。
2094
+ """
2095
+ det = aiflavor.report_line(text, task.get("type"))
2096
+ if not det:
2097
+ return tpl
2098
+ block = "## 确定性检测结果(供评审参考)\n%s\n\n" % det
2099
+ return tpl.replace("## 待评审稿件", block + "## 待评审稿件", 1)
2100
+
2101
+
2070
2102
  def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
2071
2103
  """连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
2072
2104
  import json as _json
@@ -2142,6 +2174,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2142
2174
  kb = knowledge.block_for(task)
2143
2175
  if kb:
2144
2176
  tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % kb, 1)
2177
+ # 确定性检测(AI 味/叙事架构/节奏)此前只挂在单稿件评审上,连载逐章
2178
+ # 评审从未拿到——连载是我们的旗舰场景,逐章节奏与钩子恰恰最需要这条
2179
+ # 参考线。与单稿件一致:命中才追加,只作参考不直接扣分。
2180
+ tpl = _inject_detection(tpl, text, task)
2145
2181
  return tpl.replace("__DIMKEYS__", dimkey).replace(
2146
2182
  "__MANUSCRIPT__", text or "(稿件为空!)")
2147
2183
 
@@ -2201,6 +2237,34 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2201
2237
  ("(章末钩子:%s)" % c.get("hook")) if c.get("hook") else "")
2202
2238
  for k, c in enumerate(outline["chapters"]))
2203
2239
 
2240
+ # 分卷规划表(本书级、由章号确定性推导):覆盖到本批末章即可。写成
2241
+ # 全书顶层字段,UI/报告/发布页直接读,不必重算。
2242
+ vol_plan = _book_volume_plan(task, outline, end)
2243
+ if vol_plan:
2244
+ store.update_run(run_id, volumes=vol_plan)
2245
+
2246
+ def vol_block_for(chapter):
2247
+ """单章的卷上下文:写作时知道自己身处哪一卷、卷内第几章、是否卷末。
2248
+ 不分卷返回 ""(提示词零噪音)。"""
2249
+ ent = volumes.find(vol_plan, chapter) if vol_plan else None
2250
+ if not ent:
2251
+ return ""
2252
+ pos, total = volumes.position(vol_plan, chapter)
2253
+ title = ("《%s》" % ent["title"]) if ent.get("title") else "(本卷待命名)"
2254
+ lines = ["- 本卷:第 %d 卷 %s" % (ent["vol"], title)]
2255
+ if total:
2256
+ lines.append("- 本卷位置:第 %d 章 / 共 %d 章" % (pos, total))
2257
+ else:
2258
+ lines.append("- 本卷位置:第 %d 章" % pos)
2259
+ if ent.get("arc"):
2260
+ lines.append("- 本卷弧光(本卷写作须服务这条主线):%s" % ent["arc"])
2261
+ if total and pos == total:
2262
+ lines.append("- **本章是卷末章**:必须收束本卷主线冲突,写出本卷最高潮,"
2263
+ "并在结尾留下牵出下一卷的大钩子(卷末是最重要的读者留存点)。")
2264
+ elif total and pos >= max(1, total - 1):
2265
+ lines.append("- 本章临近卷末:开始向本卷高潮收拢,不要再铺新的支线。")
2266
+ return "\n".join(lines) + "\n"
2267
+
2204
2268
  chapter_scores = [] # [{chapter,title,means,passed,rounds,words}]
2205
2269
  issues_all = []
2206
2270
 
@@ -2209,6 +2273,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2209
2273
  i = start + k - 1 # 全书章号:文件名/步骤角色/评分记录都按全书编号
2210
2274
  ch = outline["chapters"][k - 1]
2211
2275
  ch_file = "chapter-%02d.md" % i
2276
+ # 分卷上下文:评审时也带着「这是第几卷第几章、是不是卷末」——
2277
+ # 卷末章要按卷弧度收束,不符的章节不该靠全书评分才发现。
2278
+ _vpos, _vtotal = volumes.position(vol_plan, i) if vol_plan else (0, 0)
2279
+ vol_review_block = ""
2280
+ if vol_plan and _vpos:
2281
+ _vent = volumes.find(vol_plan, i)
2282
+ _vt = ("《%s》" % _vent["title"]) if (_vent and _vent.get("title")) else ""
2283
+ vol_review_block = (
2284
+ "本卷上下文:第 %d 卷%s,本章是卷内第 %d%s 章。%s\n"
2285
+ % (_vent["vol"], _vt, _vpos,
2286
+ ("/%d" % _vtotal) if _vtotal else "",
2287
+ "**卷末章**:须核对本卷主线是否收束、高潮是否到位、卷末钩子是否立住。"
2288
+ if (_vtotal and _vpos == _vtotal)
2289
+ else "核对本章是否服务于本卷主线弧光。"))
2212
2290
  # 逐项目标审稿(借鉴 AI-Novel-Writer):本章大纲要点随评审下发,评审
2213
2291
  # 以 <event_check> 回逐项判定;资源账本(借鉴角色资源账本)以 <ledger>
2214
2292
  # 回本章道具/伤情/承诺/伏笔增量,评审达标后追加账本文件供下章注入。
@@ -2220,8 +2298,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2220
2298
  "「铺垫了但没发生」不算已完成;未完成的项必须反映到对应维度评分。\n"
2221
2299
  "另在 <event_check> 块之后输出 <ledger> 块(没有新变化就整个省略):"
2222
2300
  "逐行列出本章新出现或状态变化的 道具/伤情/承诺/伏笔,格式:"
2223
- "类型|名称|现状(一句话)。\n\n"
2224
- % (ch.get("beats") or "按大纲推进", ch.get("hook") or "留下悬念"))
2301
+ "类型|名称|现状(一句话)。\n%s\n"
2302
+ % (ch.get("beats") or "按大纲推进", ch.get("hook") or "留下悬念",
2303
+ vol_review_block))
2225
2304
  ev_check_lines = [[]] # 每章重置:第一份非空评审的逐项判定
2226
2305
  ledger_lines = [] # 本章全部评审的账本增量并集
2227
2306
  ledger_txt = _read_ledger(workdir) # 截至上一章的资源账本(起草注入)
@@ -2384,6 +2463,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2384
2463
  return (SERIAL_CHAPTER_PROMPT
2385
2464
  .replace("__SKILLS__", sk_block)
2386
2465
  .replace("__SCOPE__", scope)
2466
+ .replace("__VOLUME__", vol_block_for(i))
2387
2467
  .replace("__I__", str(i)).replace("__FILE__", vfile)
2388
2468
  .replace("__GOAL__", task["goal"])
2389
2469
  .replace("__OUTLINE__", outline_txt)
@@ -2632,6 +2712,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2632
2712
  cs = dict(inh_scores[i])
2633
2713
  cs.setdefault("chapter", i)
2634
2714
  cs["reused"] = True
2715
+ _vent = volumes.find(vol_plan, i) if vol_plan else None
2716
+ if _vent:
2717
+ cs["vol"] = _vent["vol"]
2718
+ if _vent.get("title"):
2719
+ cs["vol_title"] = _vent["title"]
2635
2720
  chapter_scores.append(cs)
2636
2721
  store.update_run(run_id, chapter_scores=chapter_scores)
2637
2722
  continue
@@ -2695,6 +2780,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2695
2780
  prompt = (SERIAL_REVISE_PROMPT
2696
2781
  .replace("__I__", str(i)).replace("__FILE__", ch_file)
2697
2782
  .replace("__GOAL__", task["goal"])
2783
+ .replace("__VOLUME__", vol_block_for(i))
2698
2784
  .replace("__CRITIQUE__", "\n".join(crit_lines))
2699
2785
  .replace("__WORDS__", str(wpc)))
2700
2786
  prompt = attachments.append_task_context(prompt, task)
@@ -2710,6 +2796,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2710
2796
  "passed": bool(means) and all(v >= threshold_ch for v in means.values()),
2711
2797
  "rounds": rounds_used,
2712
2798
  "words": _wc(_read_chapter(workdir, i))}
2799
+ _vent = volumes.find(vol_plan, i) if vol_plan else None
2800
+ if _vent:
2801
+ cs_new["vol"] = _vent["vol"] # 章节卡按卷分组用
2802
+ if _vent.get("title"):
2803
+ cs_new["vol_title"] = _vent["title"]
2713
2804
  if ev_check_lines[0]:
2714
2805
  cs_new["event_check"] = ev_check_lines[0][:8] # 逐项目标审稿结果
2715
2806
  chapter_scores.append(cs_new)
@@ -2838,6 +2929,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2838
2929
  prompt = (SERIAL_REVISE_PROMPT
2839
2930
  .replace("__I__", str(i)).replace("__FILE__", "chapter-%02d.md" % i)
2840
2931
  .replace("__GOAL__", task["goal"])
2932
+ .replace("__VOLUME__", vol_block_for(i))
2841
2933
  .replace("__CRITIQUE__", crit)
2842
2934
  .replace("__WORDS__", str(wpc)))
2843
2935
  prompt = attachments.append_task_context(prompt, task)
@@ -2918,14 +3010,29 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2918
3010
  ms_name = _ms_name(task.get("manuscript"))
2919
3011
  book_title = outline.get("book_title") or task["title"]
2920
3012
  parts = ["# %s" % book_title, ""]
3013
+ # 分卷标题:卷首章之前插「第 X 卷 《卷名》」分隔(全书 1..end 都插,续写
3014
+ # 批次合并时上一批的卷标题也一并补上,不会只在首批出现)
3015
+ vol_heads = {}
3016
+ if vol_plan:
3017
+ for _ent in vol_plan:
3018
+ if _ent["first"] <= end:
3019
+ vol_heads[_ent["first"]] = _ent
2921
3020
  for i in range(1, end + 1): # 合并全书:续写时包含上一批已写好的章
3021
+ head = vol_heads.get(i)
3022
+ if head:
3023
+ vol_title = ("第 %d 卷 《%s》" % (head["vol"], head["title"])) \
3024
+ if head.get("title") else ("第 %d 卷" % head["vol"])
3025
+ parts.append("## %s" % vol_title)
3026
+ parts.append("")
2922
3027
  parts.append(_read_chapter(workdir, i).strip())
2923
3028
  parts.append("")
2924
3029
  with _ms_io(workdir, ms_name, "w") as f:
2925
3030
  f.write("\n".join(parts))
2926
3031
  total_words = _wc("\n".join(parts))
2927
3032
  store.finish_step(run_id, step["n"], "done",
2928
- summary="已合并 %d 章为 %s(约 %d 字)" % (n, ms_name, total_words),
3033
+ summary="已合并 %d 章为 %s(约 %d 字%s)" % (
3034
+ n, ms_name, total_words,
3035
+ ",分 %d 卷" % len(vol_heads) if vol_heads else ""),
2929
3036
  duration_s=0.1)
2930
3037
 
2931
3038
  chapters_pass = all(c["passed"] for c in chapter_scores)
@@ -2940,11 +3047,19 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2940
3047
  "chapter_scores": chapter_scores, "global_scores": global_means,
2941
3048
  "global_pass": global_pass, "route": route,
2942
3049
  }
3050
+ if vol_plan:
3051
+ verdict["volumes"] = vol_plan
2943
3052
  scope_txt = ("续写第 %d–%d 章,衔接前文 %d 章" % (start, end, start - 1)) if start > 1 \
2944
3053
  else ("共 %d 章" % n)
2945
3054
  lines = ["# 连载小说评审报告:%s" % task["title"], "",
2946
3055
  "- 书名:%s(%s / 约 %d 字,合并为 `%s`)" % (
2947
3056
  book_title, scope_txt, total_words, ms_name),
3057
+ "- 分卷:%s" % (";".join(
3058
+ ("第 %d 卷《%s》第 %d–%d 章" % (v["vol"], v.get("title") or "未命名",
3059
+ v["first"],
3060
+ v["last"] if v["last"] is not None else end))
3061
+ for v in vol_heads.values()) if vol_heads
3062
+ else ("未分卷(可设置「每卷章数」,或在目标里写明卷结构)")),
2948
3063
  "- 结论:**%s**(各章门禁 %s / 全局评审 %s)" % (
2949
3064
  "✅ 达到发布标准" if publishable else "❌ 未达标",
2950
3065
  "通过" if chapters_pass else "未通过",
@@ -3244,10 +3359,8 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3244
3359
  if task.get("context"):
3245
3360
  crit_prompt += "\n\n## 原始任务背景与附件参考\n" + task["context"]
3246
3361
  # AI 味确定性检测(借鉴 oh-story 去AI味):客观参考线随评审下发,
3247
- # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分
3248
- _aiflavor_line = aiflavor.report_line(manuscript)
3249
- if _aiflavor_line:
3250
- crit_prompt += "\n\n## 确定性检测结果(供评审参考)\n" + _aiflavor_line
3362
+ # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分。
3363
+ crit_prompt = _inject_detection(crit_prompt, manuscript, task)
3251
3364
  for agent in critics:
3252
3365
  role = "critique-r%d" % r
3253
3366
  if agent.get("mode") == "mock":
@@ -3698,6 +3811,20 @@ def execute_run(run_id):
3698
3811
  })
3699
3812
  mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
3700
3813
  store.update_run(run_id, mode=mode)
3814
+ # 任务生命周期钩子(task_start,2026-09-22):stdout 注入文本并入任务上下文
3815
+ # ——context 在全引擎提示词都有 __CONTEXT__ 占位,一处并入全覆盖
3816
+ try:
3817
+ hook_txt = hooks.run_event("task_start",
3818
+ ctx={"title": task.get("title") or "",
3819
+ "type": task.get("type") or "",
3820
+ "mode": mode, "goal": task.get("goal") or ""},
3821
+ task_id=task.get("id") or "", run_id=run_id)
3822
+ if hook_txt:
3823
+ task["context"] = ((task.get("context") or "") + "\n\n" +
3824
+ t("## 项目钩子注入(task_start)\n") + hook_txt).strip()
3825
+ store.update_run(run_id, hook_inject=hook_txt[:400])
3826
+ except Exception:
3827
+ pass # 外部钩子故障绝不挡主流程
3701
3828
  # engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
3702
3829
  # direct=单 CLI 直达(无拆解/评审,信箱续轮即对话)
3703
3830
  engine = task.get("engine") or ("code" if task["type"] == "code" else "review")
@@ -12,7 +12,7 @@ import os
12
12
  import re
13
13
  import time
14
14
 
15
- from . import knowledge, modelhub, runner, skills, usage
15
+ from . import knowledge, modelhub, runner, skills, usage, volumes
16
16
 
17
17
  MAX_SUBTASKS = 4
18
18
  DEFAULT_OUTLINE_TIMEOUT = 900 # 8 章大纲 + 经验包注入是重生成任务,300s 实测不够
@@ -155,12 +155,13 @@ SERIAL_OUTLINE_PROMPT = """你是网文主编,熟悉签约平台(番茄/七
155
155
  __SKILLS__
156
156
  请为下面的小说目标设计一份连载大纲:共 __N__ 章,每章约 __W__ 字。
157
157
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
158
- {"book_title": "书名", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]}
158
+ {"book_title": "书名", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]__VOLUMES_KEY__}
159
159
  硬性要求:
160
160
  - 第 1-3 章是黄金三章:第 1 章开篇即冲突+人设立住,第 3 章末留大钩子;
161
161
  - 每章有明确冲突与剧情推进,禁止水字数的日常流水账;
162
162
  - 结局必须闭环(完本感),主角有成长弧光;
163
163
  - 题材健康,无违规内容,符合平台签约调性。
164
+ __VOLUMES__
164
165
 
165
166
  ## 小说目标
166
167
  __GOAL__
@@ -173,12 +174,13 @@ SERIAL_CONTINUE_OUTLINE_PROMPT = """你是网文主编,熟悉签约平台(
173
174
  __SKILLS__
174
175
  这是一部长篇连载的续写:全书已完成前 __DONE__ 章,现在请规划第 __START__–__END__ 章
175
176
  (本批共 __N__ 章,每章约 __W__ 字)。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
176
- {"book_title": "书名(与前文保持一致)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]}
177
+ {"book_title": "书名(与前文保持一致)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]__VOLUMES_KEY__}
177
178
  硬性要求:
178
179
  - 第 1 章直接衔接前文(见下方前情),不得跳线、不得重启设定、不得复述前文;
179
180
  - 主线沿既有脉络推进,新冲突尽量从已埋伏笔中生长,人物性格与前文一致;
180
181
  - 每章有明确冲突与剧情推进,禁止水字数的日常流水账;
181
182
  - 题材健康,无违规内容,符合平台签约调性。
183
+ __VOLUMES__
182
184
 
183
185
  ## 前情大纲(已完成章节,章号为全书章号)
184
186
  __PREV_OUTLINE__
@@ -192,8 +194,23 @@ __GOAL__
192
194
  ## 背景与上下文
193
195
  __CONTEXT__"""
194
196
 
197
+ # 分卷指令块:插进大纲提示词。卷边界(哪几章属于哪一卷)已由全书章号确定,
198
+ # 是本书的不变量——模型只负责起卷名、写卷弧光,不得改动边界。不分卷时整块为空。
199
+ _VOLUMES_BLOCK = """
200
+ ## 分卷结构(必须遵守)
201
+ 本书按下列卷组织(**卷边界已按全书章号确定,是本书的不变量:不要改动章号区间、
202
+ 不要新增或合并卷**):
203
+ __VOL_PLAN__
195
204
 
196
- def _norm_chapters(data, n):
205
+ 请为**本批写作涉及的卷**补全卷名与卷弧光(已给卷名的必须原样沿用,一个字都不要改):
206
+ - 卷名:4-12 字;已给的(如《卷一 少年初入江湖》)照抄,未给的由你命名。
207
+ - 卷弧光:本卷的主线冲突、人物成长、卷末必须达到的高潮或转折(50-150 字)。
208
+ - 各卷弧光要能串成全书主线:前卷埋的钩子在后卷回收,卷末是本卷最高潮。
209
+ JSON 增加 "volumes" 字段,键为卷号字符串:
210
+ "volumes": {"1": {"title": "卷一 少年初入江湖", "arc": "…"}, "2": {"title": "…", "arc": "…"}}"""
211
+
212
+
213
+ def _norm_chapters(data, n, vol_per=0, vol_start=1, vol_plan=None):
197
214
  """规范化连载大纲输出;不合规返回 None。"""
198
215
  if not isinstance(data, dict):
199
216
  return None
@@ -215,12 +232,20 @@ def _norm_chapters(data, n):
215
232
  while len(out) < n: # 缺的章补模板位
216
233
  out.append({"title": "第 %d 章" % (len(out) + 1), "beats": "按全书目标推进剧情",
217
234
  "hook": ""})
218
- return {"book_title": str(data.get("book_title") or "").strip()[:40], "chapters": out[:n]}
235
+ res = {"book_title": str(data.get("book_title") or "").strip()[:40], "chapters": out[:n]}
236
+ # 分卷:只收本批真正用到的卷的卷名/卷弧光(模型可能多写或乱写卷号,一律按
237
+ # 本批章号范围过滤)。卷边界不在这里——它由 volumes.build_plan 从章号推导。
238
+ if vol_plan:
239
+ named = volumes.norm_volumes(data.get("volumes"), vol_per, vol_start, n)
240
+ if named:
241
+ res["volumes"] = named
242
+ return res
219
243
 
220
244
 
221
245
  def _prev_serial_story(task):
222
246
  """续写大纲的前情素材:沿 serial.continues 链收集已完成各章大纲(标全书章号)
223
- + 最新一章结尾(衔接锚点)+ 既有书名。返回 (前情文本, 已完成章数, 书名, 最新章结尾)。"""
247
+ + 最新一章结尾(衔接锚点)+ 既有书名 + 既有卷名。
248
+ 返回 (前情文本, 已完成章数, 书名, 最新章结尾, 卷名表)。"""
224
249
  from . import store # 惰性导入:store 不依赖 planner,避免测试环境导入顺序问题
225
250
  chain, seen, cur = [], set(), task
226
251
  while len(chain) < 10:
@@ -231,7 +256,7 @@ def _prev_serial_story(task):
231
256
  seen.add(prev["id"])
232
257
  chain.append(prev)
233
258
  cur = prev
234
- lines, book_title = [], ""
259
+ lines, book_title, volnames = [], "", {}
235
260
  for prev in reversed(chain): # 旧 → 新,前情按章号顺序铺开
236
261
  try:
237
262
  ps = int((prev.get("serial") or {}).get("start_chapter") or 1)
@@ -246,6 +271,7 @@ def _prev_serial_story(task):
246
271
  if not outline:
247
272
  continue
248
273
  book_title = book_title or str(outline.get("book_title") or "")
274
+ volnames.update(outline.get("volumes") or {})
249
275
  for k, c in enumerate(outline["chapters"]):
250
276
  lines.append("第 %d 章《%s》:%s" % (ps + k, c.get("title", ""),
251
277
  str(c.get("beats") or "")[:120]))
@@ -269,7 +295,52 @@ def _prev_serial_story(task):
269
295
  tail = b.decode("utf-8", "replace")[-500:].strip()
270
296
  except OSError:
271
297
  pass
272
- return "\n".join(lines), max(best_i, 0), book_title, tail
298
+ return "\n".join(lines), max(best_i, 0), book_title, tail, volnames
299
+
300
+
301
+ def _chain_volume_names(task):
302
+ """沿 serial.continues 链收集各批次大纲里的卷名/卷弧光(旧 → 新,后者覆盖)。
303
+ 续写批次合并成书时要给全书(含此前各批)的卷都写上标题,那些卷的命名记在
304
+ 更早的批次任务上,本批 run 的 outline 里没有。失败静默返回 {}。"""
305
+ from . import store # 惰性导入:与 _prev_serial_story 同一理由
306
+ names, seen, cur = {}, set(), task
307
+ chain = []
308
+ while cur and len(chain) < 20:
309
+ tid = str(cur.get("id") or "")
310
+ if not tid or tid in seen:
311
+ break
312
+ seen.add(tid)
313
+ chain.append(cur)
314
+ nxt = str((cur.get("serial") or {}).get("continues") or "")
315
+ cur = store.get_task(nxt) if nxt else None
316
+ for t in reversed(chain): # 旧 → 新:新批次的命名覆盖旧批次
317
+ try:
318
+ for r in store.task_runs(t["id"]):
319
+ o = r.get("outline") or {}
320
+ if isinstance(o.get("volumes"), dict):
321
+ names.update(o["volumes"])
322
+ except Exception:
323
+ continue
324
+ return names
325
+
326
+
327
+ def book_volume_plan(task, outline, upto):
328
+ """本书的卷规划表:显式卷表/每卷章数(serial)→ 规划表 → 合并卷名
329
+ (本批大纲 + 沿链更早批次)→ 保证覆盖到第 upto 章。
330
+
331
+ 卷边界由 volumes.build_plan 从章号确定性推导,因此同一份卷配置在任何
332
+ 批次都给出同一张表(续写不会重切卷);模型/用户只提供卷名与卷弧光。
333
+ 不分卷返回 []——调用方据此决定是否插卷标题、卷末约束。"""
334
+ serial = task.get("serial") or {}
335
+ plan = volumes.build_plan(serial.get("volumes"),
336
+ volumes.norm_per(serial.get("volume_chapters")),
337
+ upto=upto)
338
+ if not plan:
339
+ return []
340
+ named = dict(_chain_volume_names(task))
341
+ if isinstance(outline, dict):
342
+ named.update(outline.get("volumes") or {})
343
+ return volumes.merge_titles(plan, named)
273
344
 
274
345
 
275
346
  def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None):
@@ -289,15 +360,36 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
289
360
  if kb_block:
290
361
  sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
291
362
  prev_title = ""
363
+ # 分卷规划表:显式卷表 → 每卷章数,皆无则不分卷(整块提示词为空)。
364
+ # 卷边界由章号确定性推导,本批写不到的高章号也要铺出来(upto=start+n-1)。
365
+ vol_per = volumes.norm_per(serial.get("volume_chapters"))
366
+ vol_plan = volumes.build_plan(serial.get("volumes"), vol_per,
367
+ upto=start + n - 1)
368
+ prev_volnames = {}
292
369
  if start > 1:
293
- prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
370
+ prev_lines, done, prev_title, prev_tail, prev_volnames = _prev_serial_story(task)
294
371
  done = max(done, start - 1)
372
+ # 前批已命名的卷名回填进规划表:续写批次沿用同一卷名,不会给同一卷改名
373
+ if vol_plan:
374
+ vol_plan = volumes.merge_titles(vol_plan, prev_volnames)
375
+ else:
376
+ prev_lines = prev_tail = ""
377
+ if vol_plan:
378
+ vol_block = (_VOLUMES_BLOCK
379
+ .replace("__VOL_PLAN__", volumes.fmt_plan(vol_plan,
380
+ upto=start + n - 1)))
381
+ vol_key = ', "volumes": {"<卷号>": {"title": "<卷名>", "arc": "<卷弧光>"}}'
382
+ else:
383
+ vol_block, vol_key = "", ""
384
+ if start > 1:
295
385
  prompt = (SERIAL_CONTINUE_OUTLINE_PROMPT
296
386
  .replace("__SKILLS__", sk_block)
297
387
  .replace("__DONE__", str(done))
298
388
  .replace("__START__", str(start))
299
389
  .replace("__END__", str(start + n - 1))
300
390
  .replace("__N__", str(n)).replace("__W__", str(wpc))
391
+ .replace("__VOLUMES_KEY__", vol_key)
392
+ .replace("__VOLUMES__", vol_block)
301
393
  .replace("__PREV_OUTLINE__", prev_lines or "(无大纲记录,请依据下方最新一章结尾与小说目标衔接)")
302
394
  .replace("__PREV_TAIL__", prev_tail or "(无)")
303
395
  .replace("__GOAL__", task["goal"])
@@ -305,8 +397,25 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
305
397
  else:
306
398
  prompt = (SERIAL_OUTLINE_PROMPT.replace("__SKILLS__", sk_block)
307
399
  .replace("__N__", str(n)).replace("__W__", str(wpc))
400
+ .replace("__VOLUMES_KEY__", vol_key)
401
+ .replace("__VOLUMES__", vol_block)
308
402
  .replace("__GOAL__", task["goal"])
309
403
  .replace("__CONTEXT__", task.get("context") or "(无)"))
404
+
405
+ def _norm(data):
406
+ """规范化大纲 + 收卷名。卷边界不采信模型,只采信它给的卷名/卷弧光。"""
407
+ o = _norm_chapters(data, n, vol_per=vol_per, vol_start=start,
408
+ vol_plan=vol_plan)
409
+ if o and vol_plan and not o.get("volumes"):
410
+ # 模型没给卷名时,用规划表里已有的(用户给的/前批命名的)兜底,
411
+ # 保证成书卷标题不空
412
+ have = {str(e["vol"]): {"title": e.get("title") or "",
413
+ "arc": e.get("arc") or ""}
414
+ for e in vol_plan if (e.get("title") or e.get("arc"))}
415
+ if have:
416
+ o["volumes"] = have
417
+ return o
418
+
310
419
  # 续写批次打上全书章号标记;书名缺省时沿用前文
311
420
  def _mark(o):
312
421
  if o and start > 1:
@@ -347,7 +456,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
347
456
  % (_attempt, (res.get("error") or "未知错误")[:300]))
348
457
  orch_errors.append(str(res.get("error") or "返回内容无法解析为大纲"))
349
458
  data = runner.extract_json(res.get("text") or "") if res["ok"] else None
350
- outline = _norm_chapters(data, n)
459
+ outline = _norm(data)
351
460
  if outline:
352
461
  outline["source"] = "编排者(%s)" % label
353
462
  return _mark(outline)
@@ -367,7 +476,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
367
476
  if not res["ok"]:
368
477
  _append_log(log_path, "作者 CLI 失败:%s" % (res.get("error") or "")[:300])
369
478
  reason_tail = (res.get("error") or reason_tail)[:200]
370
- outline = _norm_chapters(runner.extract_json(res.get("text") or ""), n)
479
+ outline = _norm(runner.extract_json(res.get("text") or ""))
371
480
  if outline:
372
481
  outline["source"] = "llm(%s)" % author_agent["id"]
373
482
  return _mark(outline)
@@ -380,6 +489,13 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
380
489
  chapters = [{"title": "第 %d 章" % (start + i), "beats": "按全书目标推进剧情,保持冲突与钩子",
381
490
  "hook": ""} for i in range(n)]
382
491
  out = {"book_title": prev_title, "chapters": chapters, "source": "template"}
492
+ if vol_plan:
493
+ # 分卷是结构性的、与情节无关:兜底模板也给卷名(用户给的/前批命名的),
494
+ # 这样成书卷标题与章-卷归属在降级情况下依然成立。
495
+ have = {str(e["vol"]): {"title": e.get("title") or "", "arc": e.get("arc") or ""}
496
+ for e in vol_plan if (e.get("title") or e.get("arc"))}
497
+ if have:
498
+ out["volumes"] = have
383
499
  if author_agent and author_agent.get("mode") != "mock":
384
500
  out["degraded"] = True
385
501
  out["degraded_reason"] = "编排者/作者模型均未返回可用大纲(%s)" % reason_tail
package/app/core/store.py CHANGED
@@ -18,7 +18,7 @@ import threading
18
18
  import time
19
19
  from pathlib import Path
20
20
 
21
- from . import paths, runner
21
+ from . import paths, runner, volumes
22
22
 
23
23
  LOCK = threading.RLock()
24
24
  _TASKS = {}
@@ -212,6 +212,22 @@ def create_task(payload):
212
212
  v = 1
213
213
  if v > 1:
214
214
  s["variants"] = v
215
+ # 分卷:显式卷表优先(新建任务时用户明确给出的分卷信息),
216
+ # 其次每卷章数等长切卷。卷边界一律由 volumes 模块从「章号」
217
+ # 确定性推导——续写批次沿用同一份卷表/每卷章数即自动接上同一卷,
218
+ # 不会因分批而错位。
219
+ vol_spec = volumes.norm_spec(serial.get("volumes"))
220
+ if not vol_spec:
221
+ # 否则从文字里识别:表单「指定卷结构」框 > 任务目标 > 上下文。
222
+ # 保守识别——认不出返回空,绝不把普通文字误判成分卷。
223
+ vol_spec = volumes.parse_spec_text(serial.get("volumes_text")) \
224
+ or volumes.parse_spec_text(goal) \
225
+ or volumes.parse_spec_text(task.get("context") or "")
226
+ if vol_spec:
227
+ s["volumes"] = vol_spec
228
+ vper = volumes.norm_per(serial.get("volume_chapters"))
229
+ if vper:
230
+ s["volume_chapters"] = vper
215
231
  task["serial"] = s
216
232
  critics = payload.get("critics")
217
233
  if isinstance(critics, list) and critics:
@@ -708,6 +724,19 @@ def update_run(run_id, expected_status=None, **fields):
708
724
  if task:
709
725
  task["status"] = st
710
726
  _save_json(paths.TASKS_DIR / (tid + ".json"), task)
727
+ if st in ("done", "failed", "cancelled"):
728
+ # run_end 钩子(2026-09-22):终态即触发,后台跑副作用型脚本
729
+ # (回写知识库等);失败/超时静默,绝不拖慢收尾路径
730
+ try:
731
+ import threading
732
+ from . import hooks
733
+ threading.Thread(
734
+ target=hooks.run_event, args=("run_end",), daemon=True,
735
+ kwargs={"task_id": run.get("task_id") or "", "run_id": run_id,
736
+ "ctx": {"status": st, "title": run.get("title") or "",
737
+ "error": str(run.get("error") or "")[:300]}}).start()
738
+ except Exception:
739
+ pass
711
740
  return run
712
741
 
713
742
 
@@ -1371,6 +1400,11 @@ def continue_task(task_id, chapters=None):
1371
1400
  }
1372
1401
  if serial.get("variants"): # 赛马配置随链条沿用
1373
1402
  payload["serial"]["variants"] = serial["variants"]
1403
+ # 分卷配置随链条沿用:卷边界必须跨批次稳定,否则同一卷会被续写批次重切
1404
+ if serial.get("volumes"):
1405
+ payload["serial"]["volumes"] = serial["volumes"]
1406
+ if serial.get("volume_chapters"):
1407
+ payload["serial"]["volume_chapters"] = serial["volume_chapters"]
1374
1408
  for key in ("draft_prompt", "critique_prompt"): # 自定义提示词覆盖一并沿用
1375
1409
  if task.get(key):
1376
1410
  payload[key] = task[key]
@@ -1534,6 +1568,38 @@ def _ensure_messages(run):
1534
1568
  return run["messages"]
1535
1569
 
1536
1570
 
1571
+ def append_run_inject(run_id, text):
1572
+ """追加钩子注入文本到 run.hook_inject(message_submit 钩子通道)。
1573
+ 直连轮间组装提示词时消费一次并清零。返回 bool。"""
1574
+ text = str(text or "").strip()
1575
+ if not text:
1576
+ return False
1577
+ with LOCK:
1578
+ run = _RUNS.get(run_id)
1579
+ if not run:
1580
+ return False
1581
+ old = str(run.get("hook_inject") or "")
1582
+ run["hook_inject"] = (old + ("\n\n" if old and text else "") + text)[:4000]
1583
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1584
+ return True
1585
+
1586
+
1587
+ def pop_run_inject(run_id):
1588
+ """取出并清空 hook_inject(直连轮间提示词头一次性消费)。"""
1589
+ with LOCK:
1590
+ run = _RUNS.get(run_id)
1591
+ if not run:
1592
+ return ""
1593
+ txt = str(run.get("hook_inject") or "")
1594
+ if txt and "hook_inject" in run:
1595
+ run["hook_inject"] = ""
1596
+ try:
1597
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1598
+ except Exception:
1599
+ pass
1600
+ return txt
1601
+
1602
+
1537
1603
  def add_message(run_id, text, sender="本机", attachments=None):
1538
1604
  """往运行信箱追加一条用户指令。attachments 为已落盘到 workdir 的
1539
1605
  附件相对路径清单(由 main.py 提交后传入)。返回消息 dict 或 None。"""