codebee 0.1.65 → 0.1.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +520 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +796 -759
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4494
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +119 -81
  62. package/app/core/runner.py +2050 -2042
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,759 +1,796 @@
1
- # -*- coding: utf-8 -*-
2
- """作品信息一键生成:按发布平台(番茄/七猫)产出建书表单要填的开书资料。
3
-
4
- 用户在详情页「成果」分区点按钮触发(不做自动生成),后台线程调编排者模型
5
- 提炼 大纲 + 故事圣经 + 已有章节 的精华,按平台表单字段归一化后存任务
6
- book_meta 字段,同时在工作目录落一份 Markdown 归档。降级链与大纲一致:
7
- 编排者 API → 作者 CLI → 模板兜底(模板只做搬运归纳,必填字段仍保证可建书)。
8
- """
9
- from __future__ import annotations
10
-
11
- import os
12
- import re
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
- 离线环境、不想为开书资料再花一次模型调用、以及测试要确定性输出时使用;
29
- 产出仍是结构完整的字段,书名/简介直接取大纲与目标,平台必填字段由生成阶段
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
- """剥 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
-
388
- def _fill_tag_gaps(task, platform, meta, outline):
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)
412
- return meta
413
-
414
-
415
- def _material_names(material):
416
- """从圣经/大纲/章节里提取明确标注的人物名。
417
-
418
- 只接受带人物语义的短语(如“女主:阿禾”或“男主叫陆沉”),避免把
419
- 普通句子里的名词误当成主角。返回顺序去重后的最多两个名字。
420
- """
421
- text = str(material or "")
422
- out = []
423
- patterns = (
424
- r"(?:主角|主人公|男主(?:角)?|女主(?:角)?|男一|女一|姓名|名字)"
425
- r"\s*(?:是|叫|为|:|:)\s*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z·]{1,7})",
426
- r"人物\s*[::]\s*([^\n。;;]{2,40})",
427
- )
428
- for pat in patterns:
429
- for m in re.finditer(pat, text):
430
- raw = m.group(1)
431
- chunks = re.split(r"[、,,/和及与]|\s+", raw)
432
- for chunk in chunks:
433
- name = re.sub(r"[^\u4e00-\u9fffA-Za-z·]", "", chunk)
434
- if 2 <= len(name) <= 8 and name not in out:
435
- out.append(name[:5])
436
- if len(out) >= 2:
437
- return out
438
- return out
439
-
440
-
441
- def _pick_option(material, table, keywords=()):
442
- """从官方选项表中按素材命中词选择一个,完全命中不到时用首项。"""
443
- text = str(material or "")
444
- for item in table:
445
- if len(item) >= 2 and item in text:
446
- return item
447
- for key, item in keywords:
448
- if key and key in text and item in table:
449
- return item
450
- return table[0] if table else ""
451
-
452
-
453
- def _has_value(value):
454
- """判断建书字段是否真正有值(兼容列表和历史占位文本)。"""
455
- placeholders = {"待补充", "(待补充)", "(待补充)"}
456
- if isinstance(value, list):
457
- return any((text := str(x or "").strip()) and text not in placeholders
458
- for x in value)
459
- text = str(value or "").strip()
460
- return bool(text) and text not in placeholders
461
-
462
-
463
- def _fill_required_fields(task, platform, meta, outline, material):
464
- """生成阶段的必填字段闸门。
465
-
466
- ``_norm`` 仍保持“表外值清空”的纯校验语义,供旧数据修复和单测使用;
467
- 这里仅在真正交付作品信息前补齐平台建书表单的必填项,避免 UI 展示一整
468
- 屏“待补充”而无法直接复制建书。所有分类/标签仍来自官方目录。
469
- """
470
- from . import bookmeta_catalog as cat
471
-
472
- material = str(material or "")
473
- names = _material_names(material)
474
- female_words = ("女频", "女生", "女主", "言情", "古言", "恋爱")
475
- female = any(x in ((task.get("goal") or "") + material) for x in female_words)
476
- defaults = ("沈知微", "顾砚川", "林知夏") if female else ("陆沉", "苏晚", "程野")
477
- if not _has_value(meta.get("protagonist_1")):
478
- meta["protagonist_1"] = names[0] if names else defaults[0]
479
- if not _has_value(meta.get("protagonist_2")):
480
- candidate = names[1] if len(names) > 1 else defaults[1]
481
- if candidate == meta["protagonist_1"]:
482
- candidate = next((name for name in defaults if name != meta["protagonist_1"]), defaults[2])
483
- meta["protagonist_2"] = candidate
484
-
485
- if platform == "fanqie":
486
- reader = meta.get("target_reader") or ("女频" if female else "男频")
487
- if reader not in ("男频", "女频"):
488
- reader = "女频" if female else "男频"
489
- meta["target_reader"] = reader
490
- cats, theme, role, plot = _fq_tables(reader)
491
- c_plot, c_emo, c_char, c_world = _fq_content_tables()
492
- if meta.get("category") not in cats:
493
- meta["category"] = _pick_option(
494
- material, cats,
495
- (("戍边", "古风世情"), ("边关", "古风世情"),
496
- ("军", "抗战谍战"), ("悬疑", "女频悬疑" if female else "悬疑脑洞"),
497
- ("种田", "种田"), ("都市", "都市脑洞")))
498
- groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
499
- ("tags_plot", plot, 2), ("content_plot", c_plot, 4),
500
- ("content_emotion", c_emo, 2), ("content_character", c_char, 4),
501
- ("content_world", c_world, 1))
502
- else:
503
- reader = meta.get("target_reader") or ("女生" if female else "男生")
504
- if reader not in ("男生", "女生"):
505
- reader = "女生" if female else "男生"
506
- meta["target_reader"] = reader
507
- channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
508
- main = meta.get("category_main")
509
- if main not in channel:
510
- main = _pick_option(
511
- material, list(channel),
512
- (("戍边", "古代言情"), ("边关", "古代言情"),
513
- ("古代", "古代言情"), ("军事", "军事"),
514
- ("军", "军事"), ("都市", "都市"),
515
- ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")))
516
- meta["category_main"] = main
517
- subs = channel.get(main) or []
518
- sub = meta.get("category_sub")
519
- if sub not in subs:
520
- located = cat.qimao_locate_sub(sub)
521
- if located:
522
- reader, main = located
523
- meta["target_reader"] = reader
524
- meta["category_main"] = main
525
- subs = cat.QIMAO_CATS[reader][main]
526
- else:
527
- sub = ""
528
- if sub not in subs:
529
- meta["category_sub"] = _pick_option(material, subs)
530
- groups = (("tags_style", cat.QIMAO_TAG_GROUPS["风格"], 3),
531
- ("tags_role", cat.QIMAO_TAG_GROUPS["角色"], 3),
532
- ("tags_plot", cat.QIMAO_TAG_GROUPS["情节"], 3),
533
- ("tags_bg", cat.QIMAO_TAG_GROUPS["背景"], 3))
534
- for key, table, limit in groups:
535
- values = meta.get(key)
536
- if not isinstance(values, list):
537
- values = []
538
- values = [str(v).strip() for v in values if str(v or "").strip() in table]
539
- if not values:
540
- values = [_pick_option(material, table)]
541
- meta[key] = values[:limit]
542
- return meta
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", ""))
608
- if res["ok"]:
609
- meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
610
- if meta:
611
- meta = _fill_tag_gaps(task, platform, meta, outline)
612
- return _fill_required_fields(task, platform, meta, outline, material), \
613
- "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
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)
621
- if res["ok"]:
622
- meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
623
- if meta:
624
- meta = _fill_tag_gaps(task, platform, meta, outline)
625
- return _fill_required_fields(task, platform, meta, outline, material), "llm(%s)" % author_agent["id"]
626
- orch_err = orch_err or str(res.get("error") or "")[:200]
627
-
628
- meta = _template_meta(task, platform, outline)
629
- meta = _fill_required_fields(task, platform, meta, outline, material)
630
- meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
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
-
684
- def recover_orphans():
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
698
- return n
699
-
700
-
701
- def repair_existing():
702
- """补齐历史已生成记录中的必填字段。
703
-
704
- 旧版本允许分类/主角为空并把结果标成 done;只要字段缺失就按当前官方
705
- 目录和已有素材补一次,已有非空字段保持不变。返回修复条数,供启动日志
706
- 和升级诊断使用。
707
- """
708
- from . import store
709
- repaired = 0
710
- for task in store.list_tasks(limit=10 ** 9):
711
- book_meta = task.get("book_meta") or {}
712
- for platform in PLATFORMS:
713
- entry = book_meta.get(platform) or {}
714
- if entry.get("status") != "done" or not isinstance(entry.get("data"), dict):
715
- continue
716
- data = dict(entry["data"])
717
- if platform == "fanqie":
718
- reader = data.get("target_reader")
719
- cats, _, _, _ = _fq_tables(reader if reader in ("男频", "女频") else "男频")
720
- required_ok = (
721
- data.get("category") in cats
722
- and all(_has_value(data.get(key)) for key in (
723
- "tags_theme", "tags_role", "tags_plot", "content_plot",
724
- "content_emotion", "content_character", "content_world"))
725
- and _has_value(data.get("protagonist_1"))
726
- and _has_value(data.get("protagonist_2")))
727
- else:
728
- from . import bookmeta_catalog as cat
729
- reader = data.get("target_reader")
730
- channel = cat.QIMAO_CATS.get(reader) or {}
731
- main = data.get("category_main")
732
- required_ok = (
733
- main in channel
734
- and data.get("category_sub") in (channel.get(main) or [])
735
- and all(_has_value(data.get(key)) for key in (
736
- "tags_style", "tags_role", "tags_plot", "tags_bg"))
737
- and _has_value(data.get("protagonist_1"))
738
- and _has_value(data.get("protagonist_2")))
739
- if required_ok:
740
- continue
741
- try:
742
- material, outline = _collect_material(task)
743
- fixed = _fill_required_fields(task, platform, data, outline, material)
744
- if fixed == entry["data"]:
745
- continue
746
- payload = dict(entry)
747
- payload["data"] = fixed
748
- payload["source"] = (str(entry.get("source") or "") + ";已补齐必填字段").lstrip(";")
749
- store.set_book_meta(task["id"], platform, payload)
750
- try:
751
- fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
752
- if fp.parent.is_dir():
753
- fp.write_text(render_markdown(task, platform, fixed), encoding="utf-8")
754
- except OSError:
755
- pass
756
- repaired += 1
757
- except Exception:
758
- continue
759
- return repaired
1
+ # -*- coding: utf-8 -*-
2
+ """作品信息一键生成:按发布平台(番茄/七猫)产出建书表单要填的开书资料。
3
+
4
+ 用户在详情页「成果」分区点按钮触发(不做自动生成),后台线程调编排者模型
5
+ 提炼 大纲 + 故事圣经 + 已有章节 的精华,按平台表单字段归一化后存任务
6
+ book_meta 字段,同时在工作目录落一份 Markdown 归档。降级链与大纲一致:
7
+ 编排者 API → 作者 CLI → 模板兜底(模板只做搬运归纳,必填字段仍保证可建书)。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
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
+ 离线环境、不想为开书资料再花一次模型调用、以及测试要确定性输出时使用;
29
+ 产出仍是结构完整的字段,书名/简介直接取大纲与目标,平台必填字段由生成阶段
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
+ """剥 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
+
388
+ def _fill_tag_gaps(task, platform, meta, outline):
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)
412
+ return meta
413
+
414
+
415
+ def _material_names(material):
416
+ """从圣经/大纲/章节里提取明确标注的人物名。
417
+
418
+ 只接受带人物语义的短语(如“女主:阿禾”或“男主叫陆沉”),避免把
419
+ 普通句子里的名词误当成主角。返回顺序去重后的最多两个名字。
420
+ """
421
+ text = str(material or "")
422
+ out = []
423
+ patterns = (
424
+ r"(?:主角|主人公|男主(?:角)?|女主(?:角)?|男一|女一|姓名|名字)"
425
+ r"\s*(?:是|叫|为|:|:)\s*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z·]{1,7})",
426
+ r"人物\s*[::]\s*([^\n。;;]{2,40})",
427
+ )
428
+ for pat in patterns:
429
+ for m in re.finditer(pat, text):
430
+ raw = m.group(1)
431
+ chunks = re.split(r"[、,,/和及与]|\s+", raw)
432
+ for chunk in chunks:
433
+ name = re.sub(r"[^\u4e00-\u9fffA-Za-z·]", "", chunk)
434
+ if 2 <= len(name) <= 8 and name not in out:
435
+ out.append(name[:5])
436
+ if len(out) >= 2:
437
+ return out
438
+ return out
439
+
440
+
441
+ def _pick_option(material, table, keywords=()):
442
+ """从官方选项表中按素材命中词选择一个,完全命中不到时用首项。"""
443
+ text = str(material or "")
444
+ for item in table:
445
+ if len(item) >= 2 and item in text:
446
+ return item
447
+ for key, item in keywords:
448
+ if key and key in text and item in table:
449
+ return item
450
+ return table[0] if table else ""
451
+
452
+
453
+ def _has_value(value):
454
+ """判断建书字段是否真正有值(兼容列表和历史占位文本)。"""
455
+ placeholders = {"待补充", "(待补充)", "(待补充)"}
456
+ if isinstance(value, list):
457
+ return any((text := str(x or "").strip()) and text not in placeholders
458
+ for x in value)
459
+ text = str(value or "").strip()
460
+ return bool(text) and text not in placeholders
461
+
462
+
463
+ def _md_plain(text):
464
+ """markdown 轻洗(在 _summary 净化之上):每行剥标题/列表/引用符与行内
465
+ 强调符,合并成一段正文文字(建书表单简介是单行框)。"""
466
+ out = []
467
+ for ln in _summary(text).splitlines():
468
+ ln = re.sub(r"^\s*(#{1,6}\s*|[-*+]\s+|>\s*|\d+[.、))]\s*)", "", ln)
469
+ ln = re.sub(r"[*_`#>]+", "", ln).strip()
470
+ if ln:
471
+ out.append(ln)
472
+ return "".join(out)
473
+
474
+
475
+ def _summary_ok(value):
476
+ """简介够不够格进建书表单:番茄表单硬性要求 50-500 字(占位文案即
477
+ 「请输入50-500字」),markdown 标题/占位符过不了平台提交校验,且自动
478
+ 化流程看不到平台的字段报错,只会误报成「未登录或改版」。"""
479
+ return 50 <= len(_md_plain(value)) <= 500
480
+
481
+
482
+ def _prose_summary(cur, material):
483
+ """简介兜底清理:markdown 洗成正文;洗完仍不足 50 字就从素材正文取。
484
+ 取不出够长的正文时原样返回——由建书前置闸拦停并提示补写,不硬编。"""
485
+ text = _md_plain(cur)
486
+ if len(text) >= 50:
487
+ return text[:500]
488
+ body = _md_plain(material)
489
+ if len(body) >= 50:
490
+ return body[:500]
491
+ return str(cur or "").strip()
492
+
493
+
494
+ def _fill_required_fields(task, platform, meta, outline, material):
495
+ """生成阶段的必填字段闸门。
496
+
497
+ ``_norm`` 仍保持“表外值清空”的纯校验语义,供旧数据修复和单测使用;
498
+ 这里仅在真正交付作品信息前补齐平台建书表单的必填项,避免 UI 展示一整
499
+ 屏“待补充”而无法直接复制建书。所有分类/标签仍来自官方目录。
500
+ """
501
+ from . import bookmeta_catalog as cat
502
+
503
+ material = str(material or "")
504
+ names = _material_names(material)
505
+ female_words = ("女频", "女生", "女主", "言情", "古言", "恋爱")
506
+ female = any(x in ((task.get("goal") or "") + material) for x in female_words)
507
+ defaults = ("沈知微", "顾砚川", "林知夏") if female else ("陆沉", "苏晚", "程野")
508
+ if not _has_value(meta.get("protagonist_1")):
509
+ meta["protagonist_1"] = names[0] if names else defaults[0]
510
+ if not _has_value(meta.get("protagonist_2")):
511
+ candidate = names[1] if len(names) > 1 else defaults[1]
512
+ if candidate == meta["protagonist_1"]:
513
+ candidate = next((name for name in defaults if name != meta["protagonist_1"]), defaults[2])
514
+ meta["protagonist_2"] = candidate
515
+
516
+ # 简介质量:平台建书表单对简介有硬字数校验(番茄 50-500 字),markdown
517
+ # 标题/占位文本过不了提交且流程看不到报错——洗成正文,不够长从素材取
518
+ meta["summary"] = _prose_summary(meta.get("summary"), material)
519
+
520
+ if platform == "fanqie":
521
+ reader = meta.get("target_reader") or ("女频" if female else "男频")
522
+ if reader not in ("男频", "女频"):
523
+ reader = "女频" if female else "男频"
524
+ meta["target_reader"] = reader
525
+ cats, theme, role, plot = _fq_tables(reader)
526
+ c_plot, c_emo, c_char, c_world = _fq_content_tables()
527
+ if meta.get("category") not in cats:
528
+ meta["category"] = _pick_option(
529
+ material, cats,
530
+ (("戍边", "古风世情"), ("边关", "古风世情"),
531
+ ("军", "抗战谍战"), ("悬疑", "女频悬疑" if female else "悬疑脑洞"),
532
+ ("种田", "种田"), ("都市", "都市脑洞")))
533
+ groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
534
+ ("tags_plot", plot, 2), ("content_plot", c_plot, 4),
535
+ ("content_emotion", c_emo, 2), ("content_character", c_char, 4),
536
+ ("content_world", c_world, 1))
537
+ else:
538
+ reader = meta.get("target_reader") or ("女生" if female else "男生")
539
+ if reader not in ("男生", "女生"):
540
+ reader = "女生" if female else "男生"
541
+ meta["target_reader"] = reader
542
+ channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
543
+ main = meta.get("category_main")
544
+ if main not in channel:
545
+ main = _pick_option(
546
+ material, list(channel),
547
+ (("戍边", "古代言情"), ("边关", "古代言情"),
548
+ ("古代", "古代言情"), ("军事", "军事"),
549
+ ("军", "军事"), ("都市", "都市"),
550
+ ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")))
551
+ meta["category_main"] = main
552
+ subs = channel.get(main) or []
553
+ sub = meta.get("category_sub")
554
+ if sub not in subs:
555
+ located = cat.qimao_locate_sub(sub)
556
+ if located:
557
+ reader, main = located
558
+ meta["target_reader"] = reader
559
+ meta["category_main"] = main
560
+ subs = cat.QIMAO_CATS[reader][main]
561
+ else:
562
+ sub = ""
563
+ if sub not in subs:
564
+ meta["category_sub"] = _pick_option(material, subs)
565
+ groups = (("tags_style", cat.QIMAO_TAG_GROUPS["风格"], 3),
566
+ ("tags_role", cat.QIMAO_TAG_GROUPS["角色"], 3),
567
+ ("tags_plot", cat.QIMAO_TAG_GROUPS["情节"], 3),
568
+ ("tags_bg", cat.QIMAO_TAG_GROUPS["背景"], 3))
569
+ for key, table, limit in groups:
570
+ values = meta.get(key)
571
+ if not isinstance(values, list):
572
+ values = []
573
+ values = [str(v).strip() for v in values if str(v or "").strip() in table]
574
+ if not values:
575
+ values = [_pick_option(material, table)]
576
+ meta[key] = values[:limit]
577
+ return meta
578
+
579
+
580
+ def store_read_bible(task):
581
+ """圣经读取的小包装(模板兜底用;异常向上抛由调用方吞掉)。"""
582
+ from . import store
583
+ fp, text, err = store.read_story_bible(task.get("id") or "")
584
+ if err:
585
+ raise ValueError(err)
586
+ return fp, text, None
587
+
588
+
589
+ def _resolve_author(task):
590
+ """作者 CLI 兜底用:按「实现」角色选一个 real 智能体(同连载起草的路由口径)。"""
591
+ try:
592
+ from . import catalog, manager, registry, router
593
+ agents = registry.effective_agents(catalog.load(), manager.detect_all())
594
+ agent, _ = router.pick([a for a in agents if a.get("mode") == "real"],
595
+ "implement", task.get("type") or "serial_novel")
596
+ return agent
597
+ except Exception:
598
+ return None
599
+
600
+
601
+ def make_book_meta(task, platform, author_agent=None, log_path=None):
602
+ """生成一个平台的作品信息。返回 (meta dict, source 字符串);模板兜底永远有返回。"""
603
+ from . import knowledge, modelhub, planner, runner, skills # 惰性导入,同 planner
604
+ material, outline = _collect_material(task)
605
+ # 素材充分性门:没有大纲也没有章稿/圣经的任务(如「帮我定方向」的选题
606
+ # 研究任务)没有可推介的小说要素——生成只会产出报告腔垃圾
607
+ # (2026-09-23 实案:简介=「# 网文选题分析报告…」)。宁明确拒绝。
608
+ if not outline and "## 第一章开头" not in material and "## 故事圣经" not in material:
609
+ raise ValueError(
610
+ "该任务还没有小说大纲、正文或故事圣经,无法生成作品信息——"
611
+ "先完成大纲/首批章节后再来一键生成")
612
+ schema = {"fanqie": FANQIE_SCHEMA, "qimao": QIMAO_SCHEMA}.get(platform) or ""
613
+ plat_label = PLATFORMS[platform]["label"]
614
+ sk_block, _ = skills.block_for(task)
615
+ # 实案教训(2026-09-23):素材偏研究向时模型把简介写成「分析报告标题」
616
+ summary_rule = ("- summary 必须是面向读者的小说推介文案:点出题材、主角、核心冲突与悬念,"
617
+ "200/500 字内成段;绝不允许写成报告标题、要点清单或任务说明。")
618
+ kb_block = knowledge.block_for(task)
619
+ if kb_block:
620
+ sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
621
+ goal = task.get("goal") or ""
622
+ # 真实选项注入:分类/标签表按平台与读者频段给全(模型只能从表里选)
623
+ options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block(goal))
624
+ prompt = (PROMPT_HEAD.replace("__PLATFORM__", plat_label)
625
+ .replace("__SKILLS__", sk_block)
626
+ .replace("__SCHEMA__", schema)
627
+ .replace("__OPTIONS_RULE__", options + "\n" + summary_rule)
628
+ + "\n\n## 小说素材\n" + material)
629
+
630
+ orch = None
631
+ if not template_only():
632
+ try:
633
+ orch = modelhub.resolve_orchestrator()
634
+ except Exception:
635
+ orch = None
636
+ orch_err = ""
637
+ if orch:
638
+ prov, model = orch
639
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
640
+ planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
641
+ provider=prov.get("name", prov.get("id", "")),
642
+ provider_id=prov.get("id", ""))
643
+ if res["ok"]:
644
+ meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
645
+ if meta:
646
+ meta = _fill_tag_gaps(task, platform, meta, outline)
647
+ return _fill_required_fields(task, platform, meta, outline, material), \
648
+ "编排者(%s · %s)" % (prov.get("name", prov["id"]), model)
649
+ orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
650
+
651
+ if author_agent and author_agent.get("mode") == "real" and not template_only():
652
+ res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
653
+ workdir=task.get("workdir"), readonly=True,
654
+ timeout=BOOKMETA_TIMEOUT, log_path=log_path)
655
+ planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
656
+ if res["ok"]:
657
+ meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
658
+ if meta:
659
+ meta = _fill_tag_gaps(task, platform, meta, outline)
660
+ return _fill_required_fields(task, platform, meta, outline, material), "llm(%s)" % author_agent["id"]
661
+ orch_err = orch_err or str(res.get("error") or "")[:200]
662
+
663
+ meta = _template_meta(task, platform, outline)
664
+ meta = _fill_required_fields(task, platform, meta, outline, material)
665
+ meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
666
+ return meta, meta["source"]
667
+
668
+
669
+ def render_markdown(task, platform, meta):
670
+ """落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
671
+ plat = PLATFORMS[platform]["label"]
672
+ lines = ["# 作品信息(%s)" % plat, "",
673
+ "> 任务:%s 生成于 %s 来源:%s" % (
674
+ task.get("title") or task.get("id") or "",
675
+ time.strftime("%Y-%m-%d %H:%M"), meta.get("source") or "")]
676
+ lines.append("")
677
+ for key, label in FIELD_LABELS[platform]:
678
+ v = meta.get(key)
679
+ if isinstance(v, list):
680
+ v = "、".join(v) if v else "(待补充)"
681
+ elif key == "summary":
682
+ v = (v or "").strip() or "(待补充)"
683
+ else:
684
+ v = str(v or "").strip() or "(待补充)"
685
+ lines.append("- **%s**:%s" % (label, v))
686
+ lines.append("")
687
+ lines.append("> 各字段可直接复制进 %s 建书表单;分类与标签均选自平台真实选项表。" % plat)
688
+ return "\n".join(lines)
689
+
690
+
691
+ def generate_async(task_id, platform, author_agent=None):
692
+ """后台线程入口:跑生成并把终态写回任务 book_meta;成功时落工作目录归档。
693
+
694
+ 状态机:running(路由置入)→ done / failed。任何异常都落 failed,不留悬挂。"""
695
+ from . import store
696
+ task = store.get_task(task_id)
697
+ if not task:
698
+ return
699
+ try:
700
+ meta, source = make_book_meta(task, platform, author_agent=author_agent)
701
+ entry = {"status": "done", "data": meta, "source": source,
702
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
703
+ fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
704
+ if str(fp.parent) and fp.parent.is_dir():
705
+ fp.write_text(render_markdown(task, platform, meta), encoding="utf-8")
706
+ entry["file"] = PLATFORMS[platform]["file"]
707
+ except Exception as e: # 兜底线程不能静默死掉
708
+ entry = {"status": "failed", "error": str(e)[:300],
709
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
710
+ cur = store.get_task(task_id)
711
+ if not cur:
712
+ return
713
+ prev = (cur.get("book_meta") or {}).get(platform) or {}
714
+ if prev.get("status") != "running": # 期间被删/被重置:丢弃结果
715
+ return
716
+ store.set_book_meta(task_id, platform, entry)
717
+
718
+
719
+ def recover_orphans():
720
+ """启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
721
+ 前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
722
+ 遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
723
+ from . import store
724
+ n = 0
725
+ for task in store.list_tasks(limit=10 ** 9):
726
+ for plat, entry in (task.get("book_meta") or {}).items():
727
+ if isinstance(entry, dict) and entry.get("status") == "running":
728
+ store.set_book_meta(task["id"], plat, {
729
+ "status": "failed",
730
+ "error": "生成随服务重启中断,请点重试",
731
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")})
732
+ n += 1
733
+ return n
734
+
735
+
736
+ def repair_existing():
737
+ """补齐历史已生成记录中的必填字段。
738
+
739
+ 旧版本允许分类/主角为空并把结果标成 done;只要字段缺失就按当前官方
740
+ 目录和已有素材补一次,已有非空字段保持不变。返回修复条数,供启动日志
741
+ 和升级诊断使用。
742
+ """
743
+ from . import store
744
+ repaired = 0
745
+ for task in store.list_tasks(limit=10 ** 9):
746
+ book_meta = task.get("book_meta") or {}
747
+ for platform in PLATFORMS:
748
+ entry = book_meta.get(platform) or {}
749
+ if entry.get("status") != "done" or not isinstance(entry.get("data"), dict):
750
+ continue
751
+ data = dict(entry["data"])
752
+ if platform == "fanqie":
753
+ reader = data.get("target_reader")
754
+ cats, _, _, _ = _fq_tables(reader if reader in ("男频", "女频") else "男频")
755
+ required_ok = (
756
+ data.get("category") in cats
757
+ and all(_has_value(data.get(key)) for key in (
758
+ "tags_theme", "tags_role", "tags_plot", "content_plot",
759
+ "content_emotion", "content_character", "content_world"))
760
+ and _has_value(data.get("protagonist_1"))
761
+ and _has_value(data.get("protagonist_2"))
762
+ and _summary_ok(data.get("summary")))
763
+ else:
764
+ from . import bookmeta_catalog as cat
765
+ reader = data.get("target_reader")
766
+ channel = cat.QIMAO_CATS.get(reader) or {}
767
+ main = data.get("category_main")
768
+ required_ok = (
769
+ main in channel
770
+ and data.get("category_sub") in (channel.get(main) or [])
771
+ and all(_has_value(data.get(key)) for key in (
772
+ "tags_style", "tags_role", "tags_plot", "tags_bg"))
773
+ and _has_value(data.get("protagonist_1"))
774
+ and _has_value(data.get("protagonist_2"))
775
+ and _summary_ok(data.get("summary")))
776
+ if required_ok:
777
+ continue
778
+ try:
779
+ material, outline = _collect_material(task)
780
+ fixed = _fill_required_fields(task, platform, data, outline, material)
781
+ if fixed == entry["data"]:
782
+ continue
783
+ payload = dict(entry)
784
+ payload["data"] = fixed
785
+ payload["source"] = (str(entry.get("source") or "") + ";已补齐必填字段").lstrip(";")
786
+ store.set_book_meta(task["id"], platform, payload)
787
+ try:
788
+ fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
789
+ if fp.parent.is_dir():
790
+ fp.write_text(render_markdown(task, platform, fixed), encoding="utf-8")
791
+ except OSError:
792
+ pass
793
+ repaired += 1
794
+ except Exception:
795
+ continue
796
+ return repaired