codebee 0.1.4 → 0.1.6

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # 更新日志
2
+
3
+ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,并同步 README 里的
4
+ `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
+ README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
+
7
+ ## v0.1.6(2026-09-18)
8
+
9
+ - 连载韧性:网关限流/欠费时章稿失败先退避重试同作者(保住连载风格一致),重试穷尽才判死整 run;自动续跑改为延迟 5 分钟入队、上限提升到 3 次
10
+ - 修复:任务分支从游离 HEAD 检出时「切回原分支」原地空转的假成功;游离基线的合并现在显式拒绝并说明
11
+ - kimi 接入:僵尸 kimi 进程自动检测清理;CodeBee 托管配置按 TOML 语义渲染
12
+ - 修复:generic 类 CLI 经 npm .cmd 垫片时提示词被 cmd 重解析截烂(改 node 直启)
13
+ - 界面:新增蜜蜂图标,蜂巢工作台与皮肤视觉精修
14
+
15
+ ## v0.1.5(2026-09-16)
16
+
17
+ - 更新提示升级:发现新版本时直接展示「新版本更新内容」,升级后首次打开自动弹一次「本次更新内容」
18
+ - 新增 CHANGELOG.md 并随包分发
19
+
20
+ ## v0.1.4(2026-09-16)
21
+
22
+ - 修复:部分安全软件环境下启动卡在「正在启动任务队列」——worker 线程改为首次有任务时再创建,启动期不再创建任何线程
23
+ - 启动任一阶段卡超过 20 秒会自动打印各线程卡点堆栈(就绪后自动关闭),截图即可定位问题
24
+
25
+ ## v0.1.3(2026-09-16)
26
+
27
+ - 启动过程逐步骤显示进度与耗时,慢硬盘/杀软冷启动不再像卡死
28
+ - 服务就绪后明确提示「不要关闭本窗口」
29
+
30
+ ## v0.1.2(2026-09-16)
31
+
32
+ - 源码仓库地址改为 github.com/Vercel-By-WXP/CodeBee(npm 元数据同步)
33
+
34
+ ## v0.1.1(2026-09-16)
35
+
36
+ - 品牌 CodeBee 化:任务分支前缀 `codebee/<任务ID>`(老 `tutti/` 分支仍可合并/丢弃),git 提交身份改为 CodeBee
37
+ - 数据目录:新装落 `%APPDATA%\CodeBee`(macOS/Linux `~/.codebee`),老 Tutti 目录自动沿用零迁移
38
+ - 启动横幅与失败排障指引(多 Python 机器可用 `TUTTI_PYTHON` 切换)
39
+ - README 重写快速开始:npm 安装为一等路径
40
+
41
+ ## v0.1.0(2026-09-16)
42
+
43
+ - npm 首发完成:`npm install -g codebee` 一条命令即用,自带自更新
44
+ - 多智能体编排台全家桶:目标拆解/智能路由/执行/验证/跨厂商评审/连载/经验库/插件市场
package/README.md CHANGED
@@ -20,6 +20,14 @@
20
20
 
21
21
  零第三方依赖:纯 Python 标准库(3.8+),本地 Web 界面,数据全部落盘可回放。
22
22
 
23
+ <!-- relnotes:start -->
24
+ ### 最新版更新内容(v0.1.6)
25
+
26
+ - 连载更抗限流:章稿失败先退避重试同作者,自动续跑延迟 5 分钟、上限 3 次
27
+ - 修复任务分支游离 HEAD 的假成功切换与合并;kimi 僵尸进程检测与配置渲染修复
28
+ - 修复 generic CLI 经 npm 垫片时提示词被截烂;界面新增蜜蜂图标与视觉精修
29
+ <!-- relnotes:end -->
30
+
23
31
  ## 作者侧命令行(可选)
24
32
 
25
33
  不开浏览器也能过裁决队列(本机免令牌):
@@ -86,6 +86,46 @@ def save_pending(name, data_b64):
86
86
  return meta
87
87
 
88
88
 
89
+ def norm_rel(a):
90
+ """把一条附件记录归一成工作目录内相对路径(正斜杠)。
91
+
92
+ 附件只会落在 <workdir>/_attachments/ 下;老数据可能是绝对路径或裸文件名,
93
+ 统一钉回 _attachments/ 起头的相对位置,供前端「点击查看」直接走
94
+ /api/runs/<id>/file?name= 通道。取不到路径返回空串。"""
95
+ if isinstance(a, dict):
96
+ p = str(a.get("path") or a.get("name") or "")
97
+ else:
98
+ p = str(a or "")
99
+ p = p.replace("\\", "/").strip()
100
+ if not p:
101
+ return ""
102
+ if p.startswith("_attachments/"):
103
+ return p
104
+ return "_attachments/" + p.rsplit("/", 1)[-1]
105
+
106
+
107
+ def read_pending(fid):
108
+ """读待提交区附件原文(输入条胶囊点击预览用)。id 是 16 位十六进制
109
+ 随机数且只认文件名,猜不中即空。返回 (bytes, mime, name);找不到
110
+ 返回 (b"", "", "")。"""
111
+ fid = str(fid or "")
112
+ if not _ID_RE.match(fid):
113
+ return b"", "", ""
114
+ p = _pending_dir() / fid
115
+ if not p.is_file():
116
+ return b"", "", ""
117
+ meta = {}
118
+ try:
119
+ meta = json.loads((_pending_dir() / (fid + ".json")).read_text(encoding="utf-8"))
120
+ except Exception:
121
+ pass
122
+ try:
123
+ data = p.read_bytes()
124
+ except OSError:
125
+ return b"", "", ""
126
+ return data, str(meta.get("mime") or "application/octet-stream"), _safe_name(meta.get("name") or fid)
127
+
128
+
89
129
  _XML_PART_MAX = 20 * 1024 * 1024 # 单个 Office 部件(XML 条目)读入上限
90
130
 
91
131
 
@@ -44,20 +44,26 @@ def needs_book_meta(task):
44
44
 
45
45
 
46
46
  # 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
47
- # 番茄官方表单:目标读者 → 分类 → 标签(主题/角色/情节 三组多选);
48
- # 签约模式官方叫法「连载签约/完本签约」。
47
+ # 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
48
+ # +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
49
+ # 七猫官方表单:一级→二级级联按频道;作品标签四组每组必选 1-3 个。
49
50
  FIELD_LABELS = {
50
51
  "fanqie": [
51
52
  ("book_name", "作品名"), ("signing_mode", "签约模式"),
52
- ("target_reader", "目标读者"), ("category", "分类"),
53
+ ("target_reader", "目标读者"), ("category", "主分类"),
53
54
  ("tags_theme", "主题标签"), ("tags_role", "角色标签"),
54
- ("tags_plot", "情节标签"), ("protagonist_1", "主角名1"),
55
+ ("tags_plot", "情节标签"),
56
+ ("content_plot", "内容·情节"), ("content_emotion", "内容·情感"),
57
+ ("content_character", "内容·人设"), ("content_world", "内容·世界观"),
58
+ ("protagonist_1", "主角名1"),
55
59
  ("protagonist_2", "主角名2"), ("summary", "作品简介"),
56
60
  ],
57
61
  "qimao": [
58
62
  ("book_name", "作品名称"), ("target_reader", "目标读者"),
59
63
  ("category_main", "一级分类"), ("category_sub", "二级分类"),
60
- ("tags", "作品标签"), ("protagonist_1", "主角名1"),
64
+ ("tags_style", "风格标签"), ("tags_role", "角色标签"),
65
+ ("tags_plot", "情节标签"), ("tags_bg", "背景标签"),
66
+ ("protagonist_1", "主角名1"),
61
67
  ("protagonist_2", "主角名2"), ("status", "作品状态"),
62
68
  ("summary", "作品简介"),
63
69
  ],
@@ -72,24 +78,33 @@ __SKILLS__
72
78
  __SCHEMA__
73
79
  硬性要求:
74
80
  - 字段值必须贴合素材内容,不编造素材里没有的设定;简介不超过 500 字,突出卖点与钩子;
81
+ - 每个标签数组字段都必须至少给 1 个、并尽量贴着上限给满,只从对应组的选项表里选;
82
+ 任何一组留空数组、漏掉字段或自造表外词都算错误;
75
83
  - __OPTIONS_RULE__
76
84
  - 题材健康,无违规内容,符合平台签约调性。
77
85
  """
78
86
 
79
- # 签约模式官方叫法(番茄帮助中心):连载签约 / 完本签约
80
- FANQIE_MODES = ("连载签约", "完本签约")
81
-
82
- FANQIE_SCHEMA = ('{"book_name": "作品名(15字内)", "signing_mode": "连载签约完本签约", '
83
- '"target_reader": "男频 或 女频", "category": "分类(从下方分类表选一个)", '
84
- '"tags_theme": ["主题标签", …最多3个], "tags_role": ["角色标签", …最多3个], '
85
- '"tags_plot": ["情节标签", …最多5个], '
87
+ # 签约模式真实选项(番茄建书表单 2026-09 实抓):连载模式 / 完本模式
88
+ FANQIE_MODES = ("连载模式", "完本模式")
89
+
90
+ FANQIE_SCHEMA = ('{"book_name": "作品名(15字内)", "signing_mode": "连载模式完本模式", '
91
+ '"target_reader": "男频 或 女频", "category": "主分类(必选且只能选一个,从下方分类表选)", '
92
+ '"tags_theme": ["主题标签", …最多2个], "tags_role": ["角色标签", …最多2个], '
93
+ '"tags_plot": ["情节标签", …最多2个], '
94
+ '"content_plot": ["内容标签·情节", …最多4个], '
95
+ '"content_emotion": ["内容标签·情感", …最多2个], '
96
+ '"content_character": ["内容标签·人设", …最多4个], '
97
+ '"content_world": ["内容标签·世界观", …最多1个], '
86
98
  '"protagonist_1": "主角名1(5字内)", '
87
99
  '"protagonist_2": "主角名2(5字内,没有就空串)", "summary": "作品简介(500字内)"}')
88
100
 
89
- QIMAO_SCHEMA = ('{"book_name": "作品名称(15字内)", "target_reader": "男生 或 女生", '
90
- '"category_main": "一级分类(从下方一级分类表选一个)", '
101
+ QIMAO_SCHEMA = ('{"book_name": "作品名称(18字内)", "target_reader": "男生 或 女生", '
102
+ '"category_main": "一级分类(从该频道一级分类表选一个)", '
91
103
  '"category_sub": "二级分类(必须是该一级下的二级)", '
92
- '"tags": ["作品标签", …最多6个,从标签池选], '
104
+ '"tags_style": ["风格标签", 必选1-3个], '
105
+ '"tags_role": ["角色标签", 必选1-3个], '
106
+ '"tags_plot": ["情节标签", 必选1-3个], '
107
+ '"tags_bg": ["背景标签", 必选1-3个], '
93
108
  '"protagonist_1": "主角名1(5字内)", '
94
109
  '"protagonist_2": "主角名2(5字内,没有就空串)", "status": "连载中 或 已完结", '
95
110
  '"summary": "作品简介(200字内)"}')
@@ -105,25 +120,36 @@ def _fq_tables(reader):
105
120
  cat.FANQIE_MALE_TAGS_ROLE, cat.FANQIE_MALE_TAGS_PLOT)
106
121
 
107
122
 
123
+ def _fq_content_tables():
124
+ """番茄内容标签四组(男女性共用):情节/情感/人设/世界观。"""
125
+ from . import bookmeta_catalog as cat
126
+ return (cat.FANQIE_CONTENT_PLOT, cat.FANQIE_CONTENT_EMOTION,
127
+ cat.FANQIE_CONTENT_CHARACTER, cat.FANQIE_CONTENT_WORLD)
128
+
129
+
108
130
  def _fq_options_block(goal):
109
- """番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与三组标签表。"""
131
+ """番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与标签表。"""
110
132
  reader = "女频" if "女" in (goal or "") else "男频"
111
133
  cats, theme, role, plot = _fq_tables(reader)
112
- return ("目标读者(%s)可用的分类表:%s\n"
113
- "标签分三组多选——主题组:%s\n角色组:%s\n情节组(部分,选最贴合的):%s"
134
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
135
+ return ("目标读者(%s)可用的主分类表(必选且只能选一个):%s\n"
136
+ "阅读标签三组各最多选 2 个——主题组:%s\n角色组:%s\n情节组(部分,选最贴合的):%s\n"
137
+ "内容标签四组——情节组(最多4个,部分):%s\n情感组(最多2个):%s\n"
138
+ "人设组(最多4个,部分):%s\n世界观组(最多1个):%s"
114
139
  % (reader, "、".join(cats), "、".join(theme), "、".join(role),
115
- "、".join(plot[:50])))
140
+ "、".join(plot[:50]), "、".join(c_plot[:60]), "、".join(c_emo),
141
+ "、".join(c_char[:60]), "、".join(c_world)))
116
142
 
117
143
 
118
- def _qm_options_block():
119
- """七猫提示词的真实选项注入:一级分类 + 已确认级联 + 标签池。"""
144
+ def _qm_options_block(goal):
145
+ """七猫提示词的真实选项注入:按频道给级联 + 四组标签池(每组必选 1-3)。"""
120
146
  from . import bookmeta_catalog as cat
121
- cascade = ["%s(二级:%s)" % (k, "".join(v)) for k, v in cat.QIMAO_CASCADE.items()]
122
- pool = ["%s组:%s" % (g, "、".join(t)) for g, t in cat.QIMAO_TAG_GROUPS.items()]
123
- return ("一级分类表:%s\n一级→二级级联(已确认部分,二级必须从所属一级下选):%s\n"
124
- "标签池(选最贴合的 3-6 个):%s"
125
- % ("".join(cat.QIMAO_MAIN_CATEGORIES), ";".join(cascade),
126
- ";".join(pool)))
147
+ channel = cat.QIMAO_CATS.get("女生" if "女" in (goal or "") else "男生")
148
+ cascade = ["%s(二级:%s" % (m, "、".join(v)) for m, v in channel.items()]
149
+ pool = ["%s组(必选1-3个):%s" % (g, "、".join(t)) for g, t in cat.QIMAO_TAG_GROUPS.items()]
150
+ return ("一级分类表(选一个):%s\n一级→二级级联(二级必须从所属一级下选):%s\n"
151
+ "作品标签四组:%s"
152
+ % ("、".join(channel), ";".join(cascade), ";".join(pool)))
127
153
 
128
154
 
129
155
  def _collect_material(task):
@@ -162,6 +188,7 @@ def _collect_material(task):
162
188
 
163
189
 
164
190
  def _tags(v, limit=6):
191
+ """(旧字段兜底保留)通用标签列表清洗:去重/去空/截断。"""
165
192
  if not isinstance(v, list):
166
193
  return []
167
194
  out = []
@@ -219,15 +246,20 @@ def _norm(data, platform, goal=""):
219
246
  reader = "女频" if "女" in (goal or "") else "男频"
220
247
  mode = str(data.get("signing_mode") or "").strip()
221
248
  if mode not in FANQIE_MODES:
222
- mode = "连载签约"
249
+ mode = "连载模式"
223
250
  cats, theme, role, plot = _fq_tables(reader)
251
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
224
252
  # 兼容旧字段:read_tags/content_tags 并入情节/主题组(老数据不丢)
225
253
  legacy_read = data.get("read_tags") if isinstance(data.get("read_tags"), list) else []
226
254
  return {"book_name": book_name, "signing_mode": mode, "target_reader": reader,
227
255
  "category": _in_table(data.get("category"), cats),
228
- "tags_theme": _filter_table(data.get("tags_theme"), theme),
229
- "tags_role": _filter_table(data.get("tags_role"), role),
230
- "tags_plot": _filter_table(data.get("tags_plot"), plot),
256
+ "tags_theme": _filter_table(data.get("tags_theme"), theme, 2),
257
+ "tags_role": _filter_table(data.get("tags_role"), role, 2),
258
+ "tags_plot": _filter_table(data.get("tags_plot"), plot, 2),
259
+ "content_plot": _filter_table(data.get("content_plot"), c_plot, 4),
260
+ "content_emotion": _filter_table(data.get("content_emotion"), c_emo, 2),
261
+ "content_character": _filter_table(data.get("content_character"), c_char, 4),
262
+ "content_world": _filter_table(data.get("content_world"), c_world, 1),
231
263
  "legacy_read_tags": _filter_table(legacy_read, plot + theme + role),
232
264
  "protagonist_1": _name(data.get("protagonist_1"), 5),
233
265
  "protagonist_2": _name(data.get("protagonist_2"), 5),
@@ -239,14 +271,29 @@ def _norm(data, platform, goal=""):
239
271
  status = str(data.get("status") or "").strip()
240
272
  if status not in ("连载中", "已完结"):
241
273
  status = "连载中"
242
- c_main = _in_table(data.get("category_main"), cat.QIMAO_MAIN_CATEGORIES)
274
+ channel = cat.QIMAO_CATS.get(reader) or {}
275
+ c_main = _in_table(data.get("category_main"), list(channel))
243
276
  c_sub = _name(data.get("category_sub"), 10)
244
- subs = cat.QIMAO_CASCADE.get(c_main)
277
+ subs = channel.get(c_main)
245
278
  if subs and c_sub not in subs:
246
279
  c_sub = "" # 级联不匹配清空(二级必须挂在一级下)
280
+ # 作品标签四组(官方每组必选 1-3):逐组过滤 + 截到 3;老数据 tags 并入
281
+ legacy_tags = data.get("tags") if isinstance(data.get("tags"), list) else []
282
+ groups = {}
283
+ for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
284
+ ("tags_plot", "情节"), ("tags_bg", "背景")):
285
+ got = _filter_table(data.get(key), cat.QIMAO_TAG_GROUPS[gname], 3)
286
+ if len(got) < 3 and legacy_tags:
287
+ for t in _filter_table(legacy_tags, cat.QIMAO_TAG_GROUPS[gname], 3):
288
+ if t not in got:
289
+ got.append(t)
290
+ if len(got) >= 3:
291
+ break
292
+ groups[key] = got
247
293
  return {"book_name": book_name, "target_reader": reader,
248
294
  "category_main": c_main, "category_sub": c_sub,
249
- "tags": _tags(data.get("tags")),
295
+ "tags_style": groups["tags_style"], "tags_role": groups["tags_role"],
296
+ "tags_plot": groups["tags_plot"], "tags_bg": groups["tags_bg"],
250
297
  "protagonist_1": _name(data.get("protagonist_1"), 5),
251
298
  "protagonist_2": _name(data.get("protagonist_2"), 5),
252
299
  "status": status, "summary": summary}
@@ -263,27 +310,26 @@ def _template_meta(task, platform, outline):
263
310
  summary = goal.splitlines()[0][:500] if goal.strip() else ""
264
311
  if not summary:
265
312
  summary = book_name or "(待补充)" # 全空素材也要有返回,_norm 才不会拒绝
266
- material = goal
267
- try:
268
- _, bible, _ = store_read_bible(task)
269
- material += "\n" + (bible or "")
270
- except Exception:
271
- pass
272
- if outline:
273
- material += "\n" + str(outline.get("book_title") or "")
313
+ material = _material_text(task, outline)
274
314
  data = {"book_name": book_name, "summary": summary}
275
315
  if platform == "fanqie":
276
316
  reader = "女频" if "女" in goal else "男频"
277
317
  cats, theme, role, plot = _fq_tables(reader)
318
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
278
319
  data.update({"target_reader": reader,
279
320
  "category": _scan_table(material, cats, 1),
280
- "tags_theme": _scan_table(material, theme, 3),
281
- "tags_role": _scan_table(material, role, 3),
282
- "tags_plot": _scan_table(material, plot, 5)})
321
+ "tags_theme": _scan_table(material, theme, 2),
322
+ "tags_role": _scan_table(material, role, 2),
323
+ "tags_plot": _scan_table(material, plot, 2),
324
+ "content_plot": _scan_table(material, c_plot, 4),
325
+ "content_emotion": _scan_table(material, c_emo, 2),
326
+ "content_character": _scan_table(material, c_char, 4),
327
+ "content_world": _scan_table(material, c_world, 1)})
283
328
  else:
284
329
  from . import bookmeta_catalog as cat
285
- data["target_reader"] = "女生" if "女" in goal else "男生"
286
- for main, subs in cat.QIMAO_CASCADE.items():
330
+ reader = "女生" if "女" in goal else "男生"
331
+ data["target_reader"] = reader
332
+ for main, subs in (cat.QIMAO_CATS.get(reader) or {}).items():
287
333
  if main in material:
288
334
  data["category_main"] = main
289
335
  for s in subs: # 二级名撞素材即选(如「宫闱宅斗」)
@@ -291,8 +337,9 @@ def _template_meta(task, platform, outline):
291
337
  data["category_sub"] = s
292
338
  break
293
339
  break
294
- pool = [t for ts in cat.QIMAO_TAG_GROUPS.values() for t in ts]
295
- data["tags"] = _scan_table(material, pool, 6)
340
+ for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
341
+ ("tags_plot", "情节"), ("tags_bg", "背景")):
342
+ data[key] = _scan_table(material, cat.QIMAO_TAG_GROUPS[gname], 3)
296
343
  return _norm(data, platform, goal)
297
344
 
298
345
 
@@ -309,6 +356,46 @@ def _scan_table(text, table, limit):
309
356
  return out
310
357
 
311
358
 
359
+ def _material_text(task, outline):
360
+ """标签词面扫描用的素材文本:goal + 圣经 + 大纲书名(模板兜底同源)。"""
361
+ material = task.get("goal") or ""
362
+ try:
363
+ _, bible, _ = store_read_bible(task)
364
+ material += "\n" + (bible or "")
365
+ except Exception:
366
+ pass
367
+ if outline:
368
+ material += "\n" + str(outline.get("book_title") or "")
369
+ return material
370
+
371
+
372
+ def _fill_tag_gaps(task, platform, meta, outline):
373
+ """llm 归一化后仍为空的标签组:用素材词面扫描补一轮(命中多少算多少)。
374
+
375
+ 模型偶尔整组漏给(实测 codex-cli 只填了内容标签前两组);空着用户就得
376
+ 手选,扫描命中的是官方表内词,补上好过留白——已有的组不动,不覆盖模型。"""
377
+ material = _material_text(task, outline)
378
+ if not material:
379
+ return meta
380
+ goal = task.get("goal") or ""
381
+ if platform == "fanqie":
382
+ reader = meta.get("target_reader") or ("女频" if "女" in goal else "男频")
383
+ cats, theme, role, plot = _fq_tables(reader)
384
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
385
+ pairs = [("tags_theme", theme, 2), ("tags_role", role, 2), ("tags_plot", plot, 2),
386
+ ("content_plot", c_plot, 4), ("content_emotion", c_emo, 2),
387
+ ("content_character", c_char, 4), ("content_world", c_world, 1)]
388
+ else:
389
+ from . import bookmeta_catalog as cat
390
+ pairs = [(k, cat.QIMAO_TAG_GROUPS[g], 3) for k, g in
391
+ (("tags_style", "风格"), ("tags_role", "角色"),
392
+ ("tags_plot", "情节"), ("tags_bg", "背景"))]
393
+ for key, table, limit in pairs:
394
+ if not meta.get(key):
395
+ meta[key] = _scan_table(material, table, limit)
396
+ return meta
397
+
398
+
312
399
  def store_read_bible(task):
313
400
  """圣经读取的小包装(模板兜底用;异常向上抛由调用方吞掉)。"""
314
401
  from . import store
@@ -339,7 +426,7 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
339
426
  sk_block, _ = skills.block_for(task)
340
427
  goal = task.get("goal") or ""
341
428
  # 真实选项注入:分类/标签表按平台与读者频段给全(模型只能从表里选)
342
- options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block())
429
+ options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block(goal))
343
430
  prompt = (PROMPT_HEAD.replace("__PLATFORM__", plat_label)
344
431
  .replace("__SKILLS__", sk_block)
345
432
  .replace("__SCHEMA__", schema)
@@ -361,7 +448,8 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
361
448
  if res["ok"]:
362
449
  meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
363
450
  if meta:
364
- return meta, "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
451
+ return _fill_tag_gaps(task, platform, meta, outline), \
452
+ "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
365
453
  orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
366
454
 
367
455
  if author_agent and author_agent.get("mode") == "real" and not template_only():
@@ -372,7 +460,7 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
372
460
  if res["ok"]:
373
461
  meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
374
462
  if meta:
375
- return meta, "llm(%s)" % author_agent["id"]
463
+ return _fill_tag_gaps(task, platform, meta, outline), "llm(%s)" % author_agent["id"]
376
464
  orch_err = orch_err or str(res.get("error") or "")[:200]
377
465
 
378
466
  meta = _template_meta(task, platform, outline)
@@ -428,3 +516,20 @@ def generate_async(task_id, platform, author_agent=None):
428
516
  if prev.get("status") != "running": # 期间被删/被重置:丢弃结果
429
517
  return
430
518
  store.set_book_meta(task_id, platform, entry)
519
+
520
+
521
+ def recover_orphans():
522
+ """启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
523
+ 前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
524
+ 遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
525
+ from . import store
526
+ n = 0
527
+ for task in store.list_tasks(limit=10 ** 9):
528
+ for plat, entry in (task.get("book_meta") or {}).items():
529
+ if isinstance(entry, dict) and entry.get("status") == "running":
530
+ store.set_book_meta(task["id"], plat, {
531
+ "status": "failed",
532
+ "error": "生成随服务重启中断,请点重试",
533
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")})
534
+ n += 1
535
+ return n