codebee 0.1.64 → 0.1.65

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.
@@ -1,407 +1,414 @@
1
- # -*- coding: utf-8 -*-
2
- """作品信息一键生成:按发布平台(番茄/七猫)产出建书表单要填的开书资料。
3
-
4
- 用户在详情页「成果」分区点按钮触发(不做自动生成),后台线程调编排者模型
5
- 提炼 大纲 + 故事圣经 + 已有章节 的精华,按平台表单字段归一化后存任务
6
- book_meta 字段,同时在工作目录落一份 Markdown 归档。降级链与大纲一致:
1
+ # -*- coding: utf-8 -*-
2
+ """作品信息一键生成:按发布平台(番茄/七猫)产出建书表单要填的开书资料。
3
+
4
+ 用户在详情页「成果」分区点按钮触发(不做自动生成),后台线程调编排者模型
5
+ 提炼 大纲 + 故事圣经 + 已有章节 的精华,按平台表单字段归一化后存任务
6
+ book_meta 字段,同时在工作目录落一份 Markdown 归档。降级链与大纲一致:
7
7
  编排者 API → 作者 CLI → 模板兜底(模板只做搬运归纳,必填字段仍保证可建书)。
8
- """
9
- from __future__ import annotations
10
-
8
+ """
9
+ from __future__ import annotations
10
+
11
11
  import os
12
12
  import re
13
13
  import time
14
- from pathlib import Path
15
-
16
- PLATFORMS = {
17
- "fanqie": {"label": "番茄", "file": "作品信息-番茄.md"},
18
- "qimao": {"label": "七猫", "file": "作品信息-七猫.md"},
19
- }
20
- # 单平台生成上限:编排者提炼一段简介级文本是轻任务,900s 足够含重试
21
- BOOKMETA_TIMEOUT = 900
22
- _FIRST_CH_HEAD = 1500 # 注入提示词的第一章开头字符数
23
-
24
-
25
- def template_only():
26
- """只走模板兜底(跳过编排者/作者 CLI):env TUTTI_BOOKMETA_TEMPLATE_ONLY=1。
27
-
28
- 离线环境、不想为开书资料再花一次模型调用、以及测试要确定性输出时使用;
14
+ from pathlib import Path
15
+
16
+ PLATFORMS = {
17
+ "fanqie": {"label": "番茄", "file": "作品信息-番茄.md"},
18
+ "qimao": {"label": "七猫", "file": "作品信息-七猫.md"},
19
+ }
20
+ # 单平台生成上限:编排者提炼一段简介级文本是轻任务,900s 足够含重试
21
+ BOOKMETA_TIMEOUT = 900
22
+ _FIRST_CH_HEAD = 1500 # 注入提示词的第一章开头字符数
23
+
24
+
25
+ def template_only():
26
+ """只走模板兜底(跳过编排者/作者 CLI):env TUTTI_BOOKMETA_TEMPLATE_ONLY=1。
27
+
28
+ 离线环境、不想为开书资料再花一次模型调用、以及测试要确定性输出时使用;
29
29
  产出仍是结构完整的字段,书名/简介直接取大纲与目标,平台必填字段由生成阶段
30
30
  的官方目录兜底补齐。"""
31
- return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
32
-
33
-
34
- def needs_book_meta(task):
35
- """这本书是否需要开书资料:连载首批(start_chapter <= 1)才需要。
36
-
37
- 开书资料属于「这本书」而不是某一批章节——续写批次沿用第一批的书名/简介/
38
- 标签即可,重复生成只会覆盖成同内容的近义版本。非连载任务一律不需要。"""
39
- serial = (task or {}).get("serial")
40
- if not isinstance(serial, dict):
41
- return False
42
- try:
43
- return int(serial.get("start_chapter") or 1) <= 1
44
- except (TypeError, ValueError):
45
- return True
46
-
47
-
48
- # 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
49
- # 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
50
- # +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
51
- # 七猫官方表单:一级→二级级联按频道;作品标签四组每组必选 1-3 个。
52
- FIELD_LABELS = {
53
- "fanqie": [
54
- ("book_name", "作品名"), ("signing_mode", "签约模式"),
55
- ("target_reader", "目标读者"), ("category", "主分类"),
56
- ("tags_theme", "主题标签"), ("tags_role", "角色标签"),
57
- ("tags_plot", "情节标签"),
58
- ("content_plot", "内容·情节"), ("content_emotion", "内容·情感"),
59
- ("content_character", "内容·人设"), ("content_world", "内容·世界观"),
60
- ("protagonist_1", "主角名1"),
61
- ("protagonist_2", "主角名2"), ("summary", "作品简介"),
62
- ],
63
- "qimao": [
64
- ("book_name", "作品名称"), ("target_reader", "目标读者"),
65
- ("category_main", "一级分类"), ("category_sub", "二级分类"),
66
- ("tags_style", "风格标签"), ("tags_role", "角色标签"),
67
- ("tags_plot", "情节标签"), ("tags_bg", "背景标签"),
68
- ("protagonist_1", "主角名1"),
69
- ("protagonist_2", "主角名2"), ("status", "作品状态"),
70
- ("summary", "作品简介"),
71
- ],
72
- }
73
-
74
- PROMPT_HEAD = """你是网文编辑,熟悉 __PLATFORM__ 建书表单的填法。
75
-
76
- __SKILLS__
77
- 请依据下方的小说素材(目标/故事圣经/大纲/已有章节开头),为这本书生成一套
78
- 「作品信息」,直接可用于在 __PLATFORM__ 创建作品。只输出一个 ```json 代码块,
79
- 不要输出其他内容。JSON 结构:
80
- __SCHEMA__
81
- 硬性要求:
82
- - 字段值必须贴合素材内容,不编造素材里没有的设定;简介不超过 500 字,突出卖点与钩子;
83
- - 每个标签数组字段都必须至少给 1 个、并尽量贴着上限给满,只从对应组的选项表里选;
84
- 任何一组留空数组、漏掉字段或自造表外词都算错误;
85
- - __OPTIONS_RULE__
86
- - 题材健康,无违规内容,符合平台签约调性。
87
- """
88
-
89
- # 签约模式真实选项(番茄建书表单 2026-09 实抓):连载模式 / 完本模式
90
- FANQIE_MODES = ("连载模式", "完本模式")
91
-
92
- FANQIE_SCHEMA = ('{"book_name": "作品名(15字内)", "signing_mode": "连载模式 或 完本模式", '
93
- '"target_reader": "男频 或 女频", "category": "主分类(必选且只能选一个,从下方分类表选)", '
94
- '"tags_theme": ["主题标签", …最多2个], "tags_role": ["角色标签", …最多2个], '
95
- '"tags_plot": ["情节标签", …最多2个], '
96
- '"content_plot": ["内容标签·情节", …最多4个], '
97
- '"content_emotion": ["内容标签·情感", …最多2个], '
98
- '"content_character": ["内容标签·人设", …最多4个], '
99
- '"content_world": ["内容标签·世界观", …最多1个], '
100
- '"protagonist_1": "主角名1(5字内)", '
101
- '"protagonist_2": "主角名2(5字内,没有就空串)", "summary": "作品简介(500字内)"}')
102
-
103
- QIMAO_SCHEMA = ('{"book_name": "作品名称(18字内)", "target_reader": "男生 或 女生", '
104
- '"category_main": "一级分类(从该频道一级分类表选一个)", '
105
- '"category_sub": "二级分类(必须是该一级下的二级)", '
106
- '"tags_style": ["风格标签", 必选1-3个], '
107
- '"tags_role": ["角色标签", 必选1-3个], '
108
- '"tags_plot": ["情节标签", 必选1-3个], '
109
- '"tags_bg": ["背景标签", 必选1-3个], '
110
- '"protagonist_1": "主角名1(5字内)", '
111
- '"protagonist_2": "主角名2(5字内,没有就空串)", "status": "连载中 或 已完结", '
112
- '"summary": "作品简介(200字内)"}')
113
-
114
-
115
- def _fq_tables(reader):
116
- """番茄按目标读者取对应选项表(男频/女频两套完全独立的分类与标签)。"""
117
- from . import bookmeta_catalog as cat
118
- if reader == "女频":
119
- return (cat.FANQIE_FEMALE_CATEGORIES, cat.FANQIE_FEMALE_TAGS_THEME,
120
- cat.FANQIE_FEMALE_TAGS_ROLE, cat.FANQIE_FEMALE_TAGS_PLOT)
121
- return (cat.FANQIE_MALE_CATEGORIES, cat.FANQIE_MALE_TAGS_THEME,
122
- cat.FANQIE_MALE_TAGS_ROLE, cat.FANQIE_MALE_TAGS_PLOT)
123
-
124
-
125
- def _fq_content_tables():
126
- """番茄内容标签四组(男女性共用):情节/情感/人设/世界观。"""
127
- from . import bookmeta_catalog as cat
128
- return (cat.FANQIE_CONTENT_PLOT, cat.FANQIE_CONTENT_EMOTION,
129
- cat.FANQIE_CONTENT_CHARACTER, cat.FANQIE_CONTENT_WORLD)
130
-
131
-
132
- def _fq_options_block(goal):
133
- """番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与标签表。"""
134
- reader = "女频" if "女" in (goal or "") else "男频"
135
- cats, theme, role, plot = _fq_tables(reader)
136
- c_plot, c_emo, c_char, c_world = _fq_content_tables()
137
- return ("目标读者(%s)可用的主分类表(必选且只能选一个):%s\n"
138
- "阅读标签三组各最多选 2 个——主题组:%s\n角色组:%s\n情节组(部分,选最贴合的):%s\n"
139
- "内容标签四组——情节组(最多4个,部分):%s\n情感组(最多2个):%s\n"
140
- "人设组(最多4个,部分):%s\n世界观组(最多1个):%s"
141
- % (reader, "、".join(cats), "、".join(theme), "、".join(role),
142
- "、".join(plot[:50]), "、".join(c_plot[:60]), "、".join(c_emo),
143
- "、".join(c_char[:60]), "、".join(c_world)))
144
-
145
-
146
- def _qm_options_block(goal):
147
- """七猫提示词的真实选项注入:按频道给级联 + 四组标签池(每组必选 1-3)。"""
148
- from . import bookmeta_catalog as cat
149
- channel = cat.QIMAO_CATS.get("女生" if "女" in (goal or "") else "男生")
150
- cascade = ["%s(二级:%s)" % (m, "、".join(v)) for m, v in channel.items()]
151
- pool = ["%s组(必选1-3个):%s" % (g, "、".join(t)) for g, t in cat.QIMAO_TAG_GROUPS.items()]
152
- return ("一级分类表(选一个):%s\n一级→二级级联(二级必须从所属一级下选):%s\n"
153
- "作品标签四组:%s"
154
- % ("、".join(channel), ";".join(cascade), ";".join(pool)))
155
-
156
-
157
- def _collect_material(task):
158
- """开书资料素材:目标 + 附件上下文 + 故事圣经 + 最近一次大纲 + 第一章开头。"""
159
- from . import store # 惰性导入,同 planner:避免测试环境导入顺序问题
160
- parts = ["## 小说目标\n" + (task.get("goal") or "(无)")]
161
- ctx = (task.get("context") or "").strip()
162
- if ctx:
163
- parts.append("## 背景与上下文\n" + ctx[:2000])
164
- _, bible, _ = store.read_story_bible(task["id"])
165
- if bible:
166
- parts.append("## 故事圣经\n" + bible[:6000])
167
- outline = None
168
- for r in store.task_runs(task["id"]):
169
- o = r.get("outline")
170
- if o and o.get("chapters") and not o.get("degraded"):
171
- outline = o
172
- break
173
- if outline:
174
- lines = ["书名:%s" % (outline.get("book_title") or "(未定)")]
175
- for k, c in enumerate(outline["chapters"]):
176
- lines.append("第 %d 章《%s》:%s" % (k + 1, c.get("title", ""),
177
- str(c.get("beats") or "")[:120]))
178
- parts.append("## 已有大纲\n" + "\n".join(lines))
179
- head = ""
180
- try:
181
- p = Path(task.get("workdir") or "") / "chapter-001.md"
182
- if p.is_file():
183
- from . import runner
184
- head = runner.read_text_any_enc(p)[:_FIRST_CH_HEAD]
185
- except OSError:
186
- pass
187
- if head:
188
- parts.append("## 第一章开头\n" + head)
189
- return "\n\n".join(parts), outline
190
-
191
-
192
- def _tags(v, limit=6):
193
- """(旧字段兜底保留)通用标签列表清洗:去重/去空/截断。"""
194
- if not isinstance(v, list):
195
- return []
196
- out = []
197
- for x in v:
198
- s = str(x).strip()[:12]
199
- if s and s not in out:
200
- out.append(s)
201
- if len(out) >= limit:
202
- break
203
- return out
204
-
205
-
206
- def _name(v, limit):
207
- return str(v or "").strip().replace("\n", " ")[:limit]
208
-
209
-
210
- def _summary(v):
211
- return str(v or "").strip()[:500]
212
-
213
-
214
- def _in_table(v, table, limit=10):
215
- """值必须在平台真实选项表内;不在返回空串(宁缺勿错——填了假选项用户还得手动改)。"""
216
- s = _name(v, limit)
217
- return s if s in (table or []) else ""
218
-
219
-
220
- def _filter_table(v, table, limit=6):
221
- """多选标签:只保留表内项(保持模型给的顺序),去重截断。"""
222
- if not isinstance(v, list):
223
- return []
224
- out = []
225
- for x in v:
226
- s = str(x).strip()[:12]
227
- if s and s in (table or []) and s not in out:
228
- out.append(s)
229
- if len(out) >= limit:
230
- break
231
- return out
232
-
233
-
234
- def _norm(data, platform, goal=""):
235
- """规范化平台字段输出;不合规返回 None。
236
-
237
- 分类/标签按平台真实选项表校验(bookmeta_catalog):分类单选不在表内清空、
238
- 标签多选只留表内项;七猫二级按级联校验(一级无级联记录时宽松放行)。"""
239
- if not isinstance(data, dict):
240
- return None
241
- book_name = _name(data.get("book_name"), 15 if platform == "fanqie" else 15)
242
- summary = _summary(data.get("summary"))
243
- if not book_name and not summary:
244
- return None
245
- if platform == "fanqie":
246
- reader = str(data.get("target_reader") or "").strip()
247
- if reader not in ("男频", "女频"):
248
- reader = "女频" if "女" in (goal or "") else "男频"
249
- mode = str(data.get("signing_mode") or "").strip()
250
- if mode not in FANQIE_MODES:
251
- mode = "连载模式"
252
- cats, theme, role, plot = _fq_tables(reader)
253
- c_plot, c_emo, c_char, c_world = _fq_content_tables()
254
- # 兼容旧字段:read_tags/content_tags 并入情节/主题组(老数据不丢)
255
- legacy_read = data.get("read_tags") if isinstance(data.get("read_tags"), list) else []
256
- return {"book_name": book_name, "signing_mode": mode, "target_reader": reader,
257
- "category": _in_table(data.get("category"), cats),
258
- "tags_theme": _filter_table(data.get("tags_theme"), theme, 2),
259
- "tags_role": _filter_table(data.get("tags_role"), role, 2),
260
- "tags_plot": _filter_table(data.get("tags_plot"), plot, 2),
261
- "content_plot": _filter_table(data.get("content_plot"), c_plot, 4),
262
- "content_emotion": _filter_table(data.get("content_emotion"), c_emo, 2),
263
- "content_character": _filter_table(data.get("content_character"), c_char, 4),
264
- "content_world": _filter_table(data.get("content_world"), c_world, 1),
265
- "legacy_read_tags": _filter_table(legacy_read, plot + theme + role),
266
- "protagonist_1": _name(data.get("protagonist_1"), 5),
267
- "protagonist_2": _name(data.get("protagonist_2"), 5),
268
- "summary": summary}
269
- from . import bookmeta_catalog as cat
270
- reader = str(data.get("target_reader") or "").strip()
271
- if reader not in ("男生", "女生"):
272
- reader = "女生" if "女" in (goal or "") else "男生"
273
- status = str(data.get("status") or "").strip()
274
- if status not in ("连载中", "已完结"):
275
- status = "连载中"
276
- channel = cat.QIMAO_CATS.get(reader) or {}
277
- c_main = _in_table(data.get("category_main"), list(channel))
278
- c_sub = _name(data.get("category_sub"), 10)
279
- if c_sub and not (c_main and c_sub in (channel.get(c_main) or [])):
280
- # 一级/二级不自洽(一级空、或二级不属于该频道——如女生专属的
281
- # 「现实故事」配了男生频道):跨频道反查官方目录纠正,频道与
282
- # 一级跟着二级走(表单级联的选项集以此为准);全目录都没有
283
- # 才清空——表单选不出的词不硬填
284
- loc = cat.qimao_locate_sub(c_sub)
285
- if loc:
286
- reader, c_main = loc
287
- else:
288
- c_sub = ""
289
- # 作品标签四组(官方每组必选 1-3):逐组过滤 + 截到 3;老数据 tags 并入
290
- legacy_tags = data.get("tags") if isinstance(data.get("tags"), list) else []
291
- groups = {}
292
- for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
293
- ("tags_plot", "情节"), ("tags_bg", "背景")):
294
- got = _filter_table(data.get(key), cat.QIMAO_TAG_GROUPS[gname], 3)
295
- if len(got) < 3 and legacy_tags:
296
- for t in _filter_table(legacy_tags, cat.QIMAO_TAG_GROUPS[gname], 3):
297
- if t not in got:
298
- got.append(t)
299
- if len(got) >= 3:
300
- break
301
- groups[key] = got
302
- return {"book_name": book_name, "target_reader": reader,
303
- "category_main": c_main, "category_sub": c_sub,
304
- "tags_style": groups["tags_style"], "tags_role": groups["tags_role"],
305
- "tags_plot": groups["tags_plot"], "tags_bg": groups["tags_bg"],
306
- "protagonist_1": _name(data.get("protagonist_1"), 5),
307
- "protagonist_2": _name(data.get("protagonist_2"), 5),
308
- "status": status, "summary": summary}
309
-
310
-
311
- def _template_meta(task, platform, outline):
312
- """兜底模板:只做素材搬运归纳(书名/简介来自大纲与目标),其余留空待补。
313
-
314
- 标签/分类尽力而为:扫描素材文本(goal/圣经/大纲书名)里恰好命中平台
315
- 选项表的词就带上——模板没有模型理解力,不做语义归纳,命中多少算多少,
316
- 其余留「待补充」让用户手选(宁缺勿错,不填表外词)。"""
317
- goal = task.get("goal") or ""
318
- book_name = str((outline or {}).get("book_title") or task.get("title") or "").strip()
319
- summary = goal.splitlines()[0][:500] if goal.strip() else ""
320
- if not summary:
321
- summary = book_name or "(待补充)" # 全空素材也要有返回,_norm 才不会拒绝
322
- material = _material_text(task, outline)
323
- data = {"book_name": book_name, "summary": summary}
324
- if platform == "fanqie":
325
- reader = "女频" if "女" in goal else "男频"
326
- cats, theme, role, plot = _fq_tables(reader)
327
- c_plot, c_emo, c_char, c_world = _fq_content_tables()
328
- data.update({"target_reader": reader,
329
- "category": _scan_table(material, cats, 1),
330
- "tags_theme": _scan_table(material, theme, 2),
331
- "tags_role": _scan_table(material, role, 2),
332
- "tags_plot": _scan_table(material, plot, 2),
333
- "content_plot": _scan_table(material, c_plot, 4),
334
- "content_emotion": _scan_table(material, c_emo, 2),
335
- "content_character": _scan_table(material, c_char, 4),
336
- "content_world": _scan_table(material, c_world, 1)})
337
- else:
338
- from . import bookmeta_catalog as cat
339
- reader = "女生" if "女" in goal else "男生"
340
- data["target_reader"] = reader
341
- for main, subs in (cat.QIMAO_CATS.get(reader) or {}).items():
342
- if main in material:
343
- data["category_main"] = main
344
- for s in subs: # 二级名撞素材即选(如「宫闱宅斗」)
345
- if s in material:
346
- data["category_sub"] = s
347
- break
348
- break
349
- for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
350
- ("tags_plot", "情节"), ("tags_bg", "背景")):
351
- data[key] = _scan_table(material, cat.QIMAO_TAG_GROUPS[gname], 3)
352
- return _norm(data, platform, goal)
353
-
354
-
355
- def _scan_table(text, table, limit):
356
- """素材文本里命中的选项表词(按表序取,保证真实存在)。"""
357
- if not text:
358
- return []
359
- out = []
360
- for word in table:
361
- if len(word) >= 2 and word in text:
362
- out.append(word)
363
- if len(out) >= limit:
364
- break
365
- return out
366
-
367
-
368
- def _material_text(task, outline):
369
- """标签词面扫描用的素材文本:goal + 圣经 + 大纲书名(模板兜底同源)。"""
370
- material = task.get("goal") or ""
371
- try:
372
- _, bible, _ = store_read_bible(task)
373
- material += "\n" + (bible or "")
374
- except Exception:
375
- pass
376
- if outline:
377
- material += "\n" + str(outline.get("book_title") or "")
378
- return material
379
-
380
-
31
+ return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
32
+
33
+
34
+ def needs_book_meta(task):
35
+ """这本书是否需要开书资料:连载首批(start_chapter <= 1)才需要。
36
+
37
+ 开书资料属于「这本书」而不是某一批章节——续写批次沿用第一批的书名/简介/
38
+ 标签即可,重复生成只会覆盖成同内容的近义版本。非连载任务一律不需要。"""
39
+ serial = (task or {}).get("serial")
40
+ if not isinstance(serial, dict):
41
+ return False
42
+ try:
43
+ return int(serial.get("start_chapter") or 1) <= 1
44
+ except (TypeError, ValueError):
45
+ return True
46
+
47
+
48
+ # 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
49
+ # 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
50
+ # +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
51
+ # 七猫官方表单:一级→二级级联按频道;作品标签四组每组必选 1-3 个。
52
+ FIELD_LABELS = {
53
+ "fanqie": [
54
+ ("book_name", "作品名"), ("signing_mode", "签约模式"),
55
+ ("target_reader", "目标读者"), ("category", "主分类"),
56
+ ("tags_theme", "主题标签"), ("tags_role", "角色标签"),
57
+ ("tags_plot", "情节标签"),
58
+ ("content_plot", "内容·情节"), ("content_emotion", "内容·情感"),
59
+ ("content_character", "内容·人设"), ("content_world", "内容·世界观"),
60
+ ("protagonist_1", "主角名1"),
61
+ ("protagonist_2", "主角名2"), ("summary", "作品简介"),
62
+ ],
63
+ "qimao": [
64
+ ("book_name", "作品名称"), ("target_reader", "目标读者"),
65
+ ("category_main", "一级分类"), ("category_sub", "二级分类"),
66
+ ("tags_style", "风格标签"), ("tags_role", "角色标签"),
67
+ ("tags_plot", "情节标签"), ("tags_bg", "背景标签"),
68
+ ("protagonist_1", "主角名1"),
69
+ ("protagonist_2", "主角名2"), ("status", "作品状态"),
70
+ ("summary", "作品简介"),
71
+ ],
72
+ }
73
+
74
+ PROMPT_HEAD = """你是网文编辑,熟悉 __PLATFORM__ 建书表单的填法。
75
+
76
+ __SKILLS__
77
+ 请依据下方的小说素材(目标/故事圣经/大纲/已有章节开头),为这本书生成一套
78
+ 「作品信息」,直接可用于在 __PLATFORM__ 创建作品。只输出一个 ```json 代码块,
79
+ 不要输出其他内容。JSON 结构:
80
+ __SCHEMA__
81
+ 硬性要求:
82
+ - 字段值必须贴合素材内容,不编造素材里没有的设定;简介不超过 500 字,突出卖点与钩子;
83
+ - 每个标签数组字段都必须至少给 1 个、并尽量贴着上限给满,只从对应组的选项表里选;
84
+ 任何一组留空数组、漏掉字段或自造表外词都算错误;
85
+ - __OPTIONS_RULE__
86
+ - 题材健康,无违规内容,符合平台签约调性。
87
+ """
88
+
89
+ # 签约模式真实选项(番茄建书表单 2026-09 实抓):连载模式 / 完本模式
90
+ FANQIE_MODES = ("连载模式", "完本模式")
91
+
92
+ FANQIE_SCHEMA = ('{"book_name": "作品名(15字内)", "signing_mode": "连载模式 或 完本模式", '
93
+ '"target_reader": "男频 或 女频", "category": "主分类(必选且只能选一个,从下方分类表选)", '
94
+ '"tags_theme": ["主题标签", …最多2个], "tags_role": ["角色标签", …最多2个], '
95
+ '"tags_plot": ["情节标签", …最多2个], '
96
+ '"content_plot": ["内容标签·情节", …最多4个], '
97
+ '"content_emotion": ["内容标签·情感", …最多2个], '
98
+ '"content_character": ["内容标签·人设", …最多4个], '
99
+ '"content_world": ["内容标签·世界观", …最多1个], '
100
+ '"protagonist_1": "主角名1(5字内)", '
101
+ '"protagonist_2": "主角名2(5字内,没有就空串)", "summary": "作品简介(500字内)"}')
102
+
103
+ QIMAO_SCHEMA = ('{"book_name": "作品名称(18字内)", "target_reader": "男生 或 女生", '
104
+ '"category_main": "一级分类(从该频道一级分类表选一个)", '
105
+ '"category_sub": "二级分类(必须是该一级下的二级)", '
106
+ '"tags_style": ["风格标签", 必选1-3个], '
107
+ '"tags_role": ["角色标签", 必选1-3个], '
108
+ '"tags_plot": ["情节标签", 必选1-3个], '
109
+ '"tags_bg": ["背景标签", 必选1-3个], '
110
+ '"protagonist_1": "主角名1(5字内)", '
111
+ '"protagonist_2": "主角名2(5字内,没有就空串)", "status": "连载中 或 已完结", '
112
+ '"summary": "作品简介(200字内)"}')
113
+
114
+
115
+ def _fq_tables(reader):
116
+ """番茄按目标读者取对应选项表(男频/女频两套完全独立的分类与标签)。"""
117
+ from . import bookmeta_catalog as cat
118
+ if reader == "女频":
119
+ return (cat.FANQIE_FEMALE_CATEGORIES, cat.FANQIE_FEMALE_TAGS_THEME,
120
+ cat.FANQIE_FEMALE_TAGS_ROLE, cat.FANQIE_FEMALE_TAGS_PLOT)
121
+ return (cat.FANQIE_MALE_CATEGORIES, cat.FANQIE_MALE_TAGS_THEME,
122
+ cat.FANQIE_MALE_TAGS_ROLE, cat.FANQIE_MALE_TAGS_PLOT)
123
+
124
+
125
+ def _fq_content_tables():
126
+ """番茄内容标签四组(男女性共用):情节/情感/人设/世界观。"""
127
+ from . import bookmeta_catalog as cat
128
+ return (cat.FANQIE_CONTENT_PLOT, cat.FANQIE_CONTENT_EMOTION,
129
+ cat.FANQIE_CONTENT_CHARACTER, cat.FANQIE_CONTENT_WORLD)
130
+
131
+
132
+ def _fq_options_block(goal):
133
+ """番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与标签表。"""
134
+ reader = "女频" if "女" in (goal or "") else "男频"
135
+ cats, theme, role, plot = _fq_tables(reader)
136
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
137
+ return ("目标读者(%s)可用的主分类表(必选且只能选一个):%s\n"
138
+ "阅读标签三组各最多选 2 个——主题组:%s\n角色组:%s\n情节组(部分,选最贴合的):%s\n"
139
+ "内容标签四组——情节组(最多4个,部分):%s\n情感组(最多2个):%s\n"
140
+ "人设组(最多4个,部分):%s\n世界观组(最多1个):%s"
141
+ % (reader, "、".join(cats), "、".join(theme), "、".join(role),
142
+ "、".join(plot[:50]), "、".join(c_plot[:60]), "、".join(c_emo),
143
+ "、".join(c_char[:60]), "、".join(c_world)))
144
+
145
+
146
+ def _qm_options_block(goal):
147
+ """七猫提示词的真实选项注入:按频道给级联 + 四组标签池(每组必选 1-3)。"""
148
+ from . import bookmeta_catalog as cat
149
+ channel = cat.QIMAO_CATS.get("女生" if "女" in (goal or "") else "男生")
150
+ cascade = ["%s(二级:%s)" % (m, "、".join(v)) for m, v in channel.items()]
151
+ pool = ["%s组(必选1-3个):%s" % (g, "、".join(t)) for g, t in cat.QIMAO_TAG_GROUPS.items()]
152
+ return ("一级分类表(选一个):%s\n一级→二级级联(二级必须从所属一级下选):%s\n"
153
+ "作品标签四组:%s"
154
+ % ("、".join(channel), ";".join(cascade), ";".join(pool)))
155
+
156
+
157
+ def _collect_material(task):
158
+ """开书资料素材:目标 + 附件上下文 + 故事圣经 + 最近一次大纲 + 第一章开头。"""
159
+ from . import store # 惰性导入,同 planner:避免测试环境导入顺序问题
160
+ parts = ["## 小说目标\n" + (task.get("goal") or "(无)")]
161
+ ctx = (task.get("context") or "").strip()
162
+ if ctx:
163
+ parts.append("## 背景与上下文\n" + ctx[:2000])
164
+ _, bible, _ = store.read_story_bible(task["id"])
165
+ if bible:
166
+ parts.append("## 故事圣经\n" + bible[:6000])
167
+ outline = None
168
+ for r in store.task_runs(task["id"]):
169
+ o = r.get("outline")
170
+ if o and o.get("chapters") and not o.get("degraded"):
171
+ outline = o
172
+ break
173
+ if outline:
174
+ lines = ["书名:%s" % (outline.get("book_title") or "(未定)")]
175
+ for k, c in enumerate(outline["chapters"]):
176
+ lines.append("第 %d 章《%s》:%s" % (k + 1, c.get("title", ""),
177
+ str(c.get("beats") or "")[:120]))
178
+ parts.append("## 已有大纲\n" + "\n".join(lines))
179
+ head = ""
180
+ try:
181
+ p = Path(task.get("workdir") or "") / "chapter-001.md"
182
+ if p.is_file():
183
+ from . import runner
184
+ head = runner.read_text_any_enc(p)[:_FIRST_CH_HEAD]
185
+ except OSError:
186
+ pass
187
+ if head:
188
+ parts.append("## 第一章开头\n" + head)
189
+ return "\n\n".join(parts), outline
190
+
191
+
192
+ def _tags(v, limit=6):
193
+ """(旧字段兜底保留)通用标签列表清洗:去重/去空/截断。"""
194
+ if not isinstance(v, list):
195
+ return []
196
+ out = []
197
+ for x in v:
198
+ s = str(x).strip()[:12]
199
+ if s and s not in out:
200
+ out.append(s)
201
+ if len(out) >= limit:
202
+ break
203
+ return out
204
+
205
+
206
+ def _name(v, limit):
207
+ return str(v or "").strip().replace("\n", " ")[:limit]
208
+
209
+
210
+ def _summary(v):
211
+ """剥 markdown 装饰后截断。2026-09-23 实案:简介混成调研报告标题
212
+ 「# 网文选题分析报告…」——模型把研究素材当小说素材时输出的是标题行,
213
+ 至少不能把 #/* 符号和报告腔标题原样带进建书表单。"""
214
+ t = re.sub(r"^\s*#{1,6}\s*", "", str(v or "").strip())
215
+ t = re.sub(r"(?m)^\s*[-*•]\s*", "", t)
216
+ t = t.replace("**", "").replace("__", "")
217
+ t = re.sub(r"\n{2,}", "\n", t).strip()
218
+ return t[:500]
219
+
220
+
221
+ def _in_table(v, table, limit=10):
222
+ """值必须在平台真实选项表内;不在返回空串(宁缺勿错——填了假选项用户还得手动改)。"""
223
+ s = _name(v, limit)
224
+ return s if s in (table or []) else ""
225
+
226
+
227
+ def _filter_table(v, table, limit=6):
228
+ """多选标签:只保留表内项(保持模型给的顺序),去重截断。"""
229
+ if not isinstance(v, list):
230
+ return []
231
+ out = []
232
+ for x in v:
233
+ s = str(x).strip()[:12]
234
+ if s and s in (table or []) and s not in out:
235
+ out.append(s)
236
+ if len(out) >= limit:
237
+ break
238
+ return out
239
+
240
+
241
+ def _norm(data, platform, goal=""):
242
+ """规范化平台字段输出;不合规返回 None。
243
+
244
+ 分类/标签按平台真实选项表校验(bookmeta_catalog):分类单选不在表内清空、
245
+ 标签多选只留表内项;七猫二级按级联校验(一级无级联记录时宽松放行)。"""
246
+ if not isinstance(data, dict):
247
+ return None
248
+ book_name = _name(data.get("book_name"), 15 if platform == "fanqie" else 15)
249
+ summary = _summary(data.get("summary"))
250
+ if not book_name and not summary:
251
+ return None
252
+ if platform == "fanqie":
253
+ reader = str(data.get("target_reader") or "").strip()
254
+ if reader not in ("男频", "女频"):
255
+ reader = "女频" if "女" in (goal or "") else "男频"
256
+ mode = str(data.get("signing_mode") or "").strip()
257
+ if mode not in FANQIE_MODES:
258
+ mode = "连载模式"
259
+ cats, theme, role, plot = _fq_tables(reader)
260
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
261
+ # 兼容旧字段:read_tags/content_tags 并入情节/主题组(老数据不丢)
262
+ legacy_read = data.get("read_tags") if isinstance(data.get("read_tags"), list) else []
263
+ return {"book_name": book_name, "signing_mode": mode, "target_reader": reader,
264
+ "category": _in_table(data.get("category"), cats),
265
+ "tags_theme": _filter_table(data.get("tags_theme"), theme, 2),
266
+ "tags_role": _filter_table(data.get("tags_role"), role, 2),
267
+ "tags_plot": _filter_table(data.get("tags_plot"), plot, 2),
268
+ "content_plot": _filter_table(data.get("content_plot"), c_plot, 4),
269
+ "content_emotion": _filter_table(data.get("content_emotion"), c_emo, 2),
270
+ "content_character": _filter_table(data.get("content_character"), c_char, 4),
271
+ "content_world": _filter_table(data.get("content_world"), c_world, 1),
272
+ "legacy_read_tags": _filter_table(legacy_read, plot + theme + role),
273
+ "protagonist_1": _name(data.get("protagonist_1"), 5),
274
+ "protagonist_2": _name(data.get("protagonist_2"), 5),
275
+ "summary": summary}
276
+ from . import bookmeta_catalog as cat
277
+ reader = str(data.get("target_reader") or "").strip()
278
+ if reader not in ("男生", "女生"):
279
+ reader = "女生" if "女" in (goal or "") else "男生"
280
+ status = str(data.get("status") or "").strip()
281
+ if status not in ("连载中", "已完结"):
282
+ status = "连载中"
283
+ channel = cat.QIMAO_CATS.get(reader) or {}
284
+ c_main = _in_table(data.get("category_main"), list(channel))
285
+ c_sub = _name(data.get("category_sub"), 10)
286
+ if c_sub and not (c_main and c_sub in (channel.get(c_main) or [])):
287
+ # 一级/二级不自洽(一级空、或二级不属于该频道——如女生专属的
288
+ # 「现实故事」配了男生频道):跨频道反查官方目录纠正,频道与
289
+ # 一级跟着二级走(表单级联的选项集以此为准);全目录都没有
290
+ # 才清空——表单选不出的词不硬填
291
+ loc = cat.qimao_locate_sub(c_sub)
292
+ if loc:
293
+ reader, c_main = loc
294
+ else:
295
+ c_sub = ""
296
+ # 作品标签四组(官方每组必选 1-3):逐组过滤 + 截到 3;老数据 tags 并入
297
+ legacy_tags = data.get("tags") if isinstance(data.get("tags"), list) else []
298
+ groups = {}
299
+ for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
300
+ ("tags_plot", "情节"), ("tags_bg", "背景")):
301
+ got = _filter_table(data.get(key), cat.QIMAO_TAG_GROUPS[gname], 3)
302
+ if len(got) < 3 and legacy_tags:
303
+ for t in _filter_table(legacy_tags, cat.QIMAO_TAG_GROUPS[gname], 3):
304
+ if t not in got:
305
+ got.append(t)
306
+ if len(got) >= 3:
307
+ break
308
+ groups[key] = got
309
+ return {"book_name": book_name, "target_reader": reader,
310
+ "category_main": c_main, "category_sub": c_sub,
311
+ "tags_style": groups["tags_style"], "tags_role": groups["tags_role"],
312
+ "tags_plot": groups["tags_plot"], "tags_bg": groups["tags_bg"],
313
+ "protagonist_1": _name(data.get("protagonist_1"), 5),
314
+ "protagonist_2": _name(data.get("protagonist_2"), 5),
315
+ "status": status, "summary": summary}
316
+
317
+
318
+ def _template_meta(task, platform, outline):
319
+ """兜底模板:只做素材搬运归纳(书名/简介来自大纲与目标),其余留空待补。
320
+
321
+ 标签/分类尽力而为:扫描素材文本(goal/圣经/大纲书名)里恰好命中平台
322
+ 选项表的词就带上——模板没有模型理解力,不做语义归纳,命中多少算多少,
323
+ 其余留「待补充」让用户手选(宁缺勿错,不填表外词)。"""
324
+ goal = task.get("goal") or ""
325
+ book_name = str((outline or {}).get("book_title") or task.get("title") or "").strip()
326
+ summary = goal.splitlines()[0][:500] if goal.strip() else ""
327
+ if not summary:
328
+ summary = book_name or "(待补充)" # 全空素材也要有返回,_norm 才不会拒绝
329
+ material = _material_text(task, outline)
330
+ data = {"book_name": book_name, "summary": summary}
331
+ if platform == "fanqie":
332
+ reader = "女频" if "女" in goal else "男频"
333
+ cats, theme, role, plot = _fq_tables(reader)
334
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
335
+ data.update({"target_reader": reader,
336
+ "category": _scan_table(material, cats, 1),
337
+ "tags_theme": _scan_table(material, theme, 2),
338
+ "tags_role": _scan_table(material, role, 2),
339
+ "tags_plot": _scan_table(material, plot, 2),
340
+ "content_plot": _scan_table(material, c_plot, 4),
341
+ "content_emotion": _scan_table(material, c_emo, 2),
342
+ "content_character": _scan_table(material, c_char, 4),
343
+ "content_world": _scan_table(material, c_world, 1)})
344
+ else:
345
+ from . import bookmeta_catalog as cat
346
+ reader = "女生" if "女" in goal else "男生"
347
+ data["target_reader"] = reader
348
+ for main, subs in (cat.QIMAO_CATS.get(reader) or {}).items():
349
+ if main in material:
350
+ data["category_main"] = main
351
+ for s in subs: # 二级名撞素材即选(如「宫闱宅斗」)
352
+ if s in material:
353
+ data["category_sub"] = s
354
+ break
355
+ break
356
+ for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
357
+ ("tags_plot", "情节"), ("tags_bg", "背景")):
358
+ data[key] = _scan_table(material, cat.QIMAO_TAG_GROUPS[gname], 3)
359
+ return _norm(data, platform, goal)
360
+
361
+
362
+ def _scan_table(text, table, limit):
363
+ """素材文本里命中的选项表词(按表序取,保证真实存在)。"""
364
+ if not text:
365
+ return []
366
+ out = []
367
+ for word in table:
368
+ if len(word) >= 2 and word in text:
369
+ out.append(word)
370
+ if len(out) >= limit:
371
+ break
372
+ return out
373
+
374
+
375
+ def _material_text(task, outline):
376
+ """标签词面扫描用的素材文本:goal + 圣经 + 大纲书名(模板兜底同源)。"""
377
+ material = task.get("goal") or ""
378
+ try:
379
+ _, bible, _ = store_read_bible(task)
380
+ material += "\n" + (bible or "")
381
+ except Exception:
382
+ pass
383
+ if outline:
384
+ material += "\n" + str(outline.get("book_title") or "")
385
+ return material
386
+
387
+
381
388
  def _fill_tag_gaps(task, platform, meta, outline):
382
- """llm 归一化后仍为空的标签组:用素材词面扫描补一轮(命中多少算多少)。
383
-
384
- 模型偶尔整组漏给(实测 codex-cli 只填了内容标签前两组);空着用户就得
385
- 手选,扫描命中的是官方表内词,补上好过留白——已有的组不动,不覆盖模型。"""
386
- material = _material_text(task, outline)
387
- if not material:
388
- return meta
389
- goal = task.get("goal") or ""
390
- if platform == "fanqie":
391
- reader = meta.get("target_reader") or ("女频" if "女" in goal else "男频")
392
- cats, theme, role, plot = _fq_tables(reader)
393
- c_plot, c_emo, c_char, c_world = _fq_content_tables()
394
- pairs = [("tags_theme", theme, 2), ("tags_role", role, 2), ("tags_plot", plot, 2),
395
- ("content_plot", c_plot, 4), ("content_emotion", c_emo, 2),
396
- ("content_character", c_char, 4), ("content_world", c_world, 1)]
397
- else:
398
- from . import bookmeta_catalog as cat
399
- pairs = [(k, cat.QIMAO_TAG_GROUPS[g], 3) for k, g in
400
- (("tags_style", "风格"), ("tags_role", "角色"),
401
- ("tags_plot", "情节"), ("tags_bg", "背景"))]
402
- for key, table, limit in pairs:
403
- if not meta.get(key):
404
- meta[key] = _scan_table(material, table, limit)
389
+ """llm 归一化后仍为空的标签组:用素材词面扫描补一轮(命中多少算多少)。
390
+
391
+ 模型偶尔整组漏给(实测 codex-cli 只填了内容标签前两组);空着用户就得
392
+ 手选,扫描命中的是官方表内词,补上好过留白——已有的组不动,不覆盖模型。"""
393
+ material = _material_text(task, outline)
394
+ if not material:
395
+ return meta
396
+ goal = task.get("goal") or ""
397
+ if platform == "fanqie":
398
+ reader = meta.get("target_reader") or ("女频" if "女" in goal else "男频")
399
+ cats, theme, role, plot = _fq_tables(reader)
400
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
401
+ pairs = [("tags_theme", theme, 2), ("tags_role", role, 2), ("tags_plot", plot, 2),
402
+ ("content_plot", c_plot, 4), ("content_emotion", c_emo, 2),
403
+ ("content_character", c_char, 4), ("content_world", c_world, 1)]
404
+ else:
405
+ from . import bookmeta_catalog as cat
406
+ pairs = [(k, cat.QIMAO_TAG_GROUPS[g], 3) for k, g in
407
+ (("tags_style", "风格"), ("tags_role", "角色"),
408
+ ("tags_plot", "情节"), ("tags_bg", "背景"))]
409
+ for key, table, limit in pairs:
410
+ if not meta.get(key):
411
+ meta[key] = _scan_table(material, table, limit)
405
412
  return meta
406
413
 
407
414
 
@@ -533,150 +540,161 @@ def _fill_required_fields(task, platform, meta, outline, material):
533
540
  values = [_pick_option(material, table)]
534
541
  meta[key] = values[:limit]
535
542
  return meta
536
-
537
-
538
- def store_read_bible(task):
539
- """圣经读取的小包装(模板兜底用;异常向上抛由调用方吞掉)。"""
540
- from . import store
541
- fp, text, err = store.read_story_bible(task.get("id") or "")
542
- if err:
543
- raise ValueError(err)
544
- return fp, text, None
545
-
546
-
547
- def _resolve_author(task):
548
- """作者 CLI 兜底用:按「实现」角色选一个 real 智能体(同连载起草的路由口径)。"""
549
- try:
550
- from . import catalog, manager, registry, router
551
- agents = registry.effective_agents(catalog.load(), manager.detect_all())
552
- agent, _ = router.pick([a for a in agents if a.get("mode") == "real"],
553
- "implement", task.get("type") or "serial_novel")
554
- return agent
555
- except Exception:
556
- return None
557
-
558
-
559
- def make_book_meta(task, platform, author_agent=None, log_path=None):
560
- """生成一个平台的作品信息。返回 (meta dict, source 字符串);模板兜底永远有返回。"""
561
- from . import knowledge, modelhub, planner, runner, skills # 惰性导入,同 planner
562
- material, outline = _collect_material(task)
563
- schema = {"fanqie": FANQIE_SCHEMA, "qimao": QIMAO_SCHEMA}.get(platform) or ""
564
- plat_label = PLATFORMS[platform]["label"]
565
- sk_block, _ = skills.block_for(task)
566
- kb_block = knowledge.block_for(task)
567
- if kb_block:
568
- sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
569
- goal = task.get("goal") or ""
570
- # 真实选项注入:分类/标签表按平台与读者频段给全(模型只能从表里选)
571
- options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block(goal))
572
- prompt = (PROMPT_HEAD.replace("__PLATFORM__", plat_label)
573
- .replace("__SKILLS__", sk_block)
574
- .replace("__SCHEMA__", schema)
575
- .replace("__OPTIONS_RULE__", options) + "\n\n## 小说素材\n" + material)
576
-
577
- orch = None
578
- if not template_only():
579
- try:
580
- orch = modelhub.resolve_orchestrator()
581
- except Exception:
582
- orch = None
583
- orch_err = ""
584
- if orch:
585
- prov, model = orch
586
- res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
587
- planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
588
- provider=prov.get("name", prov.get("id", "")),
589
- provider_id=prov.get("id", ""))
543
+
544
+
545
+ def store_read_bible(task):
546
+ """圣经读取的小包装(模板兜底用;异常向上抛由调用方吞掉)。"""
547
+ from . import store
548
+ fp, text, err = store.read_story_bible(task.get("id") or "")
549
+ if err:
550
+ raise ValueError(err)
551
+ return fp, text, None
552
+
553
+
554
+ def _resolve_author(task):
555
+ """作者 CLI 兜底用:按「实现」角色选一个 real 智能体(同连载起草的路由口径)。"""
556
+ try:
557
+ from . import catalog, manager, registry, router
558
+ agents = registry.effective_agents(catalog.load(), manager.detect_all())
559
+ agent, _ = router.pick([a for a in agents if a.get("mode") == "real"],
560
+ "implement", task.get("type") or "serial_novel")
561
+ return agent
562
+ except Exception:
563
+ return None
564
+
565
+
566
+ def make_book_meta(task, platform, author_agent=None, log_path=None):
567
+ """生成一个平台的作品信息。返回 (meta dict, source 字符串);模板兜底永远有返回。"""
568
+ from . import knowledge, modelhub, planner, runner, skills # 惰性导入,同 planner
569
+ material, outline = _collect_material(task)
570
+ # 素材充分性门:没有大纲也没有章稿/圣经的任务(如「帮我定方向」的选题
571
+ # 研究任务)没有可推介的小说要素——生成只会产出报告腔垃圾
572
+ # (2026-09-23 实案:简介=「# 网文选题分析报告…」)。宁明确拒绝。
573
+ if not outline and "## 第一章开头" not in material and "## 故事圣经" not in material:
574
+ raise ValueError(
575
+ "该任务还没有小说大纲、正文或故事圣经,无法生成作品信息——"
576
+ "先完成大纲/首批章节后再来一键生成")
577
+ schema = {"fanqie": FANQIE_SCHEMA, "qimao": QIMAO_SCHEMA}.get(platform) or ""
578
+ plat_label = PLATFORMS[platform]["label"]
579
+ sk_block, _ = skills.block_for(task)
580
+ # 实案教训(2026-09-23):素材偏研究向时模型把简介写成「分析报告标题」
581
+ summary_rule = ("- summary 必须是面向读者的小说推介文案:点出题材、主角、核心冲突与悬念,"
582
+ "200/500 字内成段;绝不允许写成报告标题、要点清单或任务说明。")
583
+ kb_block = knowledge.block_for(task)
584
+ if kb_block:
585
+ sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
586
+ goal = task.get("goal") or ""
587
+ # 真实选项注入:分类/标签表按平台与读者频段给全(模型只能从表里选)
588
+ options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block(goal))
589
+ prompt = (PROMPT_HEAD.replace("__PLATFORM__", plat_label)
590
+ .replace("__SKILLS__", sk_block)
591
+ .replace("__SCHEMA__", schema)
592
+ .replace("__OPTIONS_RULE__", options + "\n" + summary_rule)
593
+ + "\n\n## 小说素材\n" + material)
594
+
595
+ orch = None
596
+ if not template_only():
597
+ try:
598
+ orch = modelhub.resolve_orchestrator()
599
+ except Exception:
600
+ orch = None
601
+ orch_err = ""
602
+ if orch:
603
+ prov, model = orch
604
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
605
+ planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
606
+ provider=prov.get("name", prov.get("id", "")),
607
+ provider_id=prov.get("id", ""))
590
608
  if res["ok"]:
591
609
  meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
592
610
  if meta:
593
611
  meta = _fill_tag_gaps(task, platform, meta, outline)
594
612
  return _fill_required_fields(task, platform, meta, outline, material), \
595
613
  "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
596
- orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
597
-
598
- if author_agent and author_agent.get("mode") == "real" and not template_only():
599
- res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
600
- workdir=task.get("workdir"), readonly=True,
601
- timeout=BOOKMETA_TIMEOUT, log_path=log_path)
602
- planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
614
+ orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
615
+
616
+ if author_agent and author_agent.get("mode") == "real" and not template_only():
617
+ res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
618
+ workdir=task.get("workdir"), readonly=True,
619
+ timeout=BOOKMETA_TIMEOUT, log_path=log_path)
620
+ planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
603
621
  if res["ok"]:
604
622
  meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
605
623
  if meta:
606
624
  meta = _fill_tag_gaps(task, platform, meta, outline)
607
625
  return _fill_required_fields(task, platform, meta, outline, material), "llm(%s)" % author_agent["id"]
608
- orch_err = orch_err or str(res.get("error") or "")[:200]
609
-
626
+ orch_err = orch_err or str(res.get("error") or "")[:200]
627
+
610
628
  meta = _template_meta(task, platform, outline)
611
629
  meta = _fill_required_fields(task, platform, meta, outline, material)
612
630
  meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
613
- return meta, meta["source"]
614
-
615
-
616
- def render_markdown(task, platform, meta):
617
- """落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
618
- plat = PLATFORMS[platform]["label"]
619
- lines = ["# 作品信息(%s)" % plat, "",
620
- "> 任务:%s 生成于 %s 来源:%s" % (
621
- task.get("title") or task.get("id") or "",
622
- time.strftime("%Y-%m-%d %H:%M"), meta.get("source") or "")]
623
- lines.append("")
624
- for key, label in FIELD_LABELS[platform]:
625
- v = meta.get(key)
626
- if isinstance(v, list):
627
- v = "、".join(v) if v else "(待补充)"
628
- elif key == "summary":
629
- v = (v or "").strip() or "(待补充)"
630
- else:
631
- v = str(v or "").strip() or "(待补充)"
632
- lines.append("- **%s**:%s" % (label, v))
633
- lines.append("")
634
- lines.append("> 各字段可直接复制进 %s 建书表单;分类与标签均选自平台真实选项表。" % plat)
635
- return "\n".join(lines)
636
-
637
-
638
- def generate_async(task_id, platform, author_agent=None):
639
- """后台线程入口:跑生成并把终态写回任务 book_meta;成功时落工作目录归档。
640
-
641
- 状态机:running(路由置入)→ done / failed。任何异常都落 failed,不留悬挂。"""
642
- from . import store
643
- task = store.get_task(task_id)
644
- if not task:
645
- return
646
- try:
647
- meta, source = make_book_meta(task, platform, author_agent=author_agent)
648
- entry = {"status": "done", "data": meta, "source": source,
649
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}
650
- fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
651
- if str(fp.parent) and fp.parent.is_dir():
652
- fp.write_text(render_markdown(task, platform, meta), encoding="utf-8")
653
- entry["file"] = PLATFORMS[platform]["file"]
654
- except Exception as e: # 兜底线程不能静默死掉
655
- entry = {"status": "failed", "error": str(e)[:300],
656
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}
657
- cur = store.get_task(task_id)
658
- if not cur:
659
- return
660
- prev = (cur.get("book_meta") or {}).get(platform) or {}
661
- if prev.get("status") != "running": # 期间被删/被重置:丢弃结果
662
- return
663
- store.set_book_meta(task_id, platform, entry)
664
-
665
-
631
+ return meta, meta["source"]
632
+
633
+
634
+ def render_markdown(task, platform, meta):
635
+ """落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
636
+ plat = PLATFORMS[platform]["label"]
637
+ lines = ["# 作品信息(%s)" % plat, "",
638
+ "> 任务:%s 生成于 %s 来源:%s" % (
639
+ task.get("title") or task.get("id") or "",
640
+ time.strftime("%Y-%m-%d %H:%M"), meta.get("source") or "")]
641
+ lines.append("")
642
+ for key, label in FIELD_LABELS[platform]:
643
+ v = meta.get(key)
644
+ if isinstance(v, list):
645
+ v = "、".join(v) if v else "(待补充)"
646
+ elif key == "summary":
647
+ v = (v or "").strip() or "(待补充)"
648
+ else:
649
+ v = str(v or "").strip() or "(待补充)"
650
+ lines.append("- **%s**:%s" % (label, v))
651
+ lines.append("")
652
+ lines.append("> 各字段可直接复制进 %s 建书表单;分类与标签均选自平台真实选项表。" % plat)
653
+ return "\n".join(lines)
654
+
655
+
656
+ def generate_async(task_id, platform, author_agent=None):
657
+ """后台线程入口:跑生成并把终态写回任务 book_meta;成功时落工作目录归档。
658
+
659
+ 状态机:running(路由置入)→ done / failed。任何异常都落 failed,不留悬挂。"""
660
+ from . import store
661
+ task = store.get_task(task_id)
662
+ if not task:
663
+ return
664
+ try:
665
+ meta, source = make_book_meta(task, platform, author_agent=author_agent)
666
+ entry = {"status": "done", "data": meta, "source": source,
667
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
668
+ fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
669
+ if str(fp.parent) and fp.parent.is_dir():
670
+ fp.write_text(render_markdown(task, platform, meta), encoding="utf-8")
671
+ entry["file"] = PLATFORMS[platform]["file"]
672
+ except Exception as e: # 兜底线程不能静默死掉
673
+ entry = {"status": "failed", "error": str(e)[:300],
674
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
675
+ cur = store.get_task(task_id)
676
+ if not cur:
677
+ return
678
+ prev = (cur.get("book_meta") or {}).get(platform) or {}
679
+ if prev.get("status") != "running": # 期间被删/被重置:丢弃结果
680
+ return
681
+ store.set_book_meta(task_id, platform, entry)
682
+
683
+
666
684
  def recover_orphans():
667
- """启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
668
- 前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
669
- 遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
670
- from . import store
671
- n = 0
672
- for task in store.list_tasks(limit=10 ** 9):
673
- for plat, entry in (task.get("book_meta") or {}).items():
674
- if isinstance(entry, dict) and entry.get("status") == "running":
675
- store.set_book_meta(task["id"], plat, {
676
- "status": "failed",
677
- "error": "生成随服务重启中断,请点重试",
678
- "at": time.strftime("%Y-%m-%d %H:%M:%S")})
679
- n += 1
685
+ """启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
686
+ 前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
687
+ 遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
688
+ from . import store
689
+ n = 0
690
+ for task in store.list_tasks(limit=10 ** 9):
691
+ for plat, entry in (task.get("book_meta") or {}).items():
692
+ if isinstance(entry, dict) and entry.get("status") == "running":
693
+ store.set_book_meta(task["id"], plat, {
694
+ "status": "failed",
695
+ "error": "生成随服务重启中断,请点重试",
696
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")})
697
+ n += 1
680
698
  return n
681
699
 
682
700