codebee 0.1.47 → 0.1.48

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.
@@ -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,7 @@ 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
273
299
 
274
300
 
275
301
  def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None):
@@ -289,15 +315,36 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
289
315
  if kb_block:
290
316
  sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
291
317
  prev_title = ""
318
+ # 分卷规划表:显式卷表 → 每卷章数,皆无则不分卷(整块提示词为空)。
319
+ # 卷边界由章号确定性推导,本批写不到的高章号也要铺出来(upto=start+n-1)。
320
+ vol_per = volumes.norm_per(serial.get("volume_chapters"))
321
+ vol_plan = volumes.build_plan(serial.get("volumes"), vol_per,
322
+ upto=start + n - 1)
323
+ prev_volnames = {}
292
324
  if start > 1:
293
- prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
325
+ prev_lines, done, prev_title, prev_tail, prev_volnames = _prev_serial_story(task)
294
326
  done = max(done, start - 1)
327
+ # 前批已命名的卷名回填进规划表:续写批次沿用同一卷名,不会给同一卷改名
328
+ if vol_plan:
329
+ vol_plan = volumes.merge_titles(vol_plan, prev_volnames)
330
+ else:
331
+ prev_lines = prev_tail = ""
332
+ if vol_plan:
333
+ vol_block = (_VOLUMES_BLOCK
334
+ .replace("__VOL_PLAN__", volumes.fmt_plan(vol_plan,
335
+ upto=start + n - 1)))
336
+ vol_key = ', "volumes": {"<卷号>": {"title": "<卷名>", "arc": "<卷弧光>"}}'
337
+ else:
338
+ vol_block, vol_key = "", ""
339
+ if start > 1:
295
340
  prompt = (SERIAL_CONTINUE_OUTLINE_PROMPT
296
341
  .replace("__SKILLS__", sk_block)
297
342
  .replace("__DONE__", str(done))
298
343
  .replace("__START__", str(start))
299
344
  .replace("__END__", str(start + n - 1))
300
345
  .replace("__N__", str(n)).replace("__W__", str(wpc))
346
+ .replace("__VOLUMES_KEY__", vol_key)
347
+ .replace("__VOLUMES__", vol_block)
301
348
  .replace("__PREV_OUTLINE__", prev_lines or "(无大纲记录,请依据下方最新一章结尾与小说目标衔接)")
302
349
  .replace("__PREV_TAIL__", prev_tail or "(无)")
303
350
  .replace("__GOAL__", task["goal"])
@@ -305,8 +352,25 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
305
352
  else:
306
353
  prompt = (SERIAL_OUTLINE_PROMPT.replace("__SKILLS__", sk_block)
307
354
  .replace("__N__", str(n)).replace("__W__", str(wpc))
355
+ .replace("__VOLUMES_KEY__", vol_key)
356
+ .replace("__VOLUMES__", vol_block)
308
357
  .replace("__GOAL__", task["goal"])
309
358
  .replace("__CONTEXT__", task.get("context") or "(无)"))
359
+
360
+ def _norm(data):
361
+ """规范化大纲 + 收卷名。卷边界不采信模型,只采信它给的卷名/卷弧光。"""
362
+ o = _norm_chapters(data, n, vol_per=vol_per, vol_start=start,
363
+ vol_plan=vol_plan)
364
+ if o and vol_plan and not o.get("volumes"):
365
+ # 模型没给卷名时,用规划表里已有的(用户给的/前批命名的)兜底,
366
+ # 保证成书卷标题不空
367
+ have = {str(e["vol"]): {"title": e.get("title") or "",
368
+ "arc": e.get("arc") or ""}
369
+ for e in vol_plan if (e.get("title") or e.get("arc"))}
370
+ if have:
371
+ o["volumes"] = have
372
+ return o
373
+
310
374
  # 续写批次打上全书章号标记;书名缺省时沿用前文
311
375
  def _mark(o):
312
376
  if o and start > 1:
@@ -347,7 +411,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
347
411
  % (_attempt, (res.get("error") or "未知错误")[:300]))
348
412
  orch_errors.append(str(res.get("error") or "返回内容无法解析为大纲"))
349
413
  data = runner.extract_json(res.get("text") or "") if res["ok"] else None
350
- outline = _norm_chapters(data, n)
414
+ outline = _norm(data)
351
415
  if outline:
352
416
  outline["source"] = "编排者(%s)" % label
353
417
  return _mark(outline)
@@ -367,7 +431,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
367
431
  if not res["ok"]:
368
432
  _append_log(log_path, "作者 CLI 失败:%s" % (res.get("error") or "")[:300])
369
433
  reason_tail = (res.get("error") or reason_tail)[:200]
370
- outline = _norm_chapters(runner.extract_json(res.get("text") or ""), n)
434
+ outline = _norm(runner.extract_json(res.get("text") or ""))
371
435
  if outline:
372
436
  outline["source"] = "llm(%s)" % author_agent["id"]
373
437
  return _mark(outline)
@@ -380,6 +444,13 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
380
444
  chapters = [{"title": "第 %d 章" % (start + i), "beats": "按全书目标推进剧情,保持冲突与钩子",
381
445
  "hook": ""} for i in range(n)]
382
446
  out = {"book_title": prev_title, "chapters": chapters, "source": "template"}
447
+ if vol_plan:
448
+ # 分卷是结构性的、与情节无关:兜底模板也给卷名(用户给的/前批命名的),
449
+ # 这样成书卷标题与章-卷归属在降级情况下依然成立。
450
+ have = {str(e["vol"]): {"title": e.get("title") or "", "arc": e.get("arc") or ""}
451
+ for e in vol_plan if (e.get("title") or e.get("arc"))}
452
+ if have:
453
+ out["volumes"] = have
383
454
  if author_agent and author_agent.get("mode") != "mock":
384
455
  out["degraded"] = True
385
456
  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,23 @@ 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
+ # 「第一卷 少年初入江湖 第1-20章」式分卷信息(保守识别,
223
+ # 认不出就返回空,绝不会把普通文字误判成分卷)。
224
+ vol_spec = volumes.parse_spec_text(goal) \
225
+ or volumes.parse_spec_text(serial.get("volumes_text")) \
226
+ or volumes.parse_spec_text(task.get("context") or "")
227
+ if vol_spec:
228
+ s["volumes"] = vol_spec
229
+ vper = volumes.norm_per(serial.get("volume_chapters"))
230
+ if vper:
231
+ s["volume_chapters"] = vper
215
232
  task["serial"] = s
216
233
  critics = payload.get("critics")
217
234
  if isinstance(critics, list) and critics:
@@ -708,6 +725,19 @@ def update_run(run_id, expected_status=None, **fields):
708
725
  if task:
709
726
  task["status"] = st
710
727
  _save_json(paths.TASKS_DIR / (tid + ".json"), task)
728
+ if st in ("done", "failed", "cancelled"):
729
+ # run_end 钩子(2026-09-22):终态即触发,后台跑副作用型脚本
730
+ # (回写知识库等);失败/超时静默,绝不拖慢收尾路径
731
+ try:
732
+ import threading
733
+ from . import hooks
734
+ threading.Thread(
735
+ target=hooks.run_event, args=("run_end",), daemon=True,
736
+ kwargs={"task_id": run.get("task_id") or "", "run_id": run_id,
737
+ "ctx": {"status": st, "title": run.get("title") or "",
738
+ "error": str(run.get("error") or "")[:300]}}).start()
739
+ except Exception:
740
+ pass
711
741
  return run
712
742
 
713
743
 
@@ -1371,6 +1401,11 @@ def continue_task(task_id, chapters=None):
1371
1401
  }
1372
1402
  if serial.get("variants"): # 赛马配置随链条沿用
1373
1403
  payload["serial"]["variants"] = serial["variants"]
1404
+ # 分卷配置随链条沿用:卷边界必须跨批次稳定,否则同一卷会被续写批次重切
1405
+ if serial.get("volumes"):
1406
+ payload["serial"]["volumes"] = serial["volumes"]
1407
+ if serial.get("volume_chapters"):
1408
+ payload["serial"]["volume_chapters"] = serial["volume_chapters"]
1374
1409
  for key in ("draft_prompt", "critique_prompt"): # 自定义提示词覆盖一并沿用
1375
1410
  if task.get(key):
1376
1411
  payload[key] = task[key]
@@ -1534,6 +1569,38 @@ def _ensure_messages(run):
1534
1569
  return run["messages"]
1535
1570
 
1536
1571
 
1572
+ def append_run_inject(run_id, text):
1573
+ """追加钩子注入文本到 run.hook_inject(message_submit 钩子通道)。
1574
+ 直连轮间组装提示词时消费一次并清零。返回 bool。"""
1575
+ text = str(text or "").strip()
1576
+ if not text:
1577
+ return False
1578
+ with LOCK:
1579
+ run = _RUNS.get(run_id)
1580
+ if not run:
1581
+ return False
1582
+ old = str(run.get("hook_inject") or "")
1583
+ run["hook_inject"] = (old + ("\n\n" if old and text else "") + text)[:4000]
1584
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1585
+ return True
1586
+
1587
+
1588
+ def pop_run_inject(run_id):
1589
+ """取出并清空 hook_inject(直连轮间提示词头一次性消费)。"""
1590
+ with LOCK:
1591
+ run = _RUNS.get(run_id)
1592
+ if not run:
1593
+ return ""
1594
+ txt = str(run.get("hook_inject") or "")
1595
+ if txt and "hook_inject" in run:
1596
+ run["hook_inject"] = ""
1597
+ try:
1598
+ _save_json(paths.RUNS_DIR / run_id / "run.json", run)
1599
+ except Exception:
1600
+ pass
1601
+ return txt
1602
+
1603
+
1537
1604
  def add_message(run_id, text, sender="本机", attachments=None):
1538
1605
  """往运行信箱追加一条用户指令。attachments 为已落盘到 workdir 的
1539
1606
  附件相对路径清单(由 main.py 提交后传入)。返回消息 dict 或 None。"""