codebee 0.1.95 → 0.1.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +725 -720
- package/LICENSE +21 -21
- package/README.md +515 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +143 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +43 -19
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +52 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +26 -3
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +210 -165
- package/app/core/publish/flow.py +753 -640
- package/app/core/publish/flows-fanqie-calibrated.json +349 -336
- package/app/core/publish/ledger.py +13 -1
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2238 -2238
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +20 -0
- package/app/core/settings_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +9 -0
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +104 -9
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +286 -18
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +33 -0
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +32 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +196 -2
- package/bin/tutti.js +147 -147
- package/package.json +48 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/bookmeta.py
CHANGED
|
@@ -1,1036 +1,1036 @@
|
|
|
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
|
-
# 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
|
|
35
|
-
# 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
|
|
36
|
-
# +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
|
|
37
|
-
# 七猫官方表单:一级→二级级联按频道;作品标签四组每组必选 1-3 个。
|
|
38
|
-
FIELD_LABELS = {
|
|
39
|
-
"fanqie": [
|
|
40
|
-
("book_name", "作品名"), ("signing_mode", "签约模式"),
|
|
41
|
-
("target_reader", "目标读者"), ("category", "主分类"),
|
|
42
|
-
("tags_theme", "主题标签"), ("tags_role", "角色标签"),
|
|
43
|
-
("tags_plot", "情节标签"),
|
|
44
|
-
("content_plot", "内容·情节"), ("content_emotion", "内容·情感"),
|
|
45
|
-
("content_character", "内容·人设"), ("content_world", "内容·世界观"),
|
|
46
|
-
("protagonist_1", "主角名1"),
|
|
47
|
-
("protagonist_2", "主角名2"), ("summary", "作品简介"),
|
|
48
|
-
],
|
|
49
|
-
"qimao": [
|
|
50
|
-
("book_name", "作品名称"), ("target_reader", "目标读者"),
|
|
51
|
-
("category_main", "一级分类"), ("category_sub", "二级分类"),
|
|
52
|
-
("tags_style", "风格标签"), ("tags_role", "角色标签"),
|
|
53
|
-
("tags_plot", "情节标签"), ("tags_bg", "背景标签"),
|
|
54
|
-
("protagonist_1", "主角名1"),
|
|
55
|
-
("protagonist_2", "主角名2"), ("status", "作品状态"),
|
|
56
|
-
("summary", "作品简介"),
|
|
57
|
-
],
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
PROMPT_HEAD = """你是网文编辑,熟悉 __PLATFORM__ 建书表单的填法。
|
|
61
|
-
|
|
62
|
-
__SKILLS__
|
|
63
|
-
请依据下方的小说素材(目标/故事圣经/大纲/已有章节开头),为这本书生成一套
|
|
64
|
-
「作品信息」,直接可用于在 __PLATFORM__ 创建作品。只输出一个 ```json 代码块,
|
|
65
|
-
不要输出其他内容。JSON 结构:
|
|
66
|
-
__SCHEMA__
|
|
67
|
-
硬性要求:
|
|
68
|
-
- 字段值必须贴合素材内容,不编造素材里没有的设定;简介不超过 500 字,突出卖点与钩子;
|
|
69
|
-
- 每个标签数组字段都必须至少给 1 个、并尽量贴着上限给满,只从对应组的选项表里选;
|
|
70
|
-
任何一组留空数组、漏掉字段或自造表外词都算错误;
|
|
71
|
-
- __OPTIONS_RULE__
|
|
72
|
-
- 题材健康,无违规内容,符合平台签约调性。
|
|
73
|
-
"""
|
|
74
|
-
|
|
75
|
-
# 签约模式真实选项(番茄建书表单 2026-09 实抓):连载模式 / 完本模式
|
|
76
|
-
FANQIE_MODES = ("连载模式", "完本模式")
|
|
77
|
-
|
|
78
|
-
FANQIE_SCHEMA = ('{"book_name": "作品名(15字内)", "signing_mode": "连载模式 或 完本模式", '
|
|
79
|
-
'"target_reader": "男频 或 女频", "category": "主分类(必选且只能选一个,从下方分类表选)", '
|
|
80
|
-
'"tags_theme": ["主题标签", …最多2个], "tags_role": ["角色标签", …最多2个], '
|
|
81
|
-
'"tags_plot": ["情节标签", …最多2个], '
|
|
82
|
-
'"content_plot": ["内容标签·情节", …最多4个], '
|
|
83
|
-
'"content_emotion": ["内容标签·情感", …最多2个], '
|
|
84
|
-
'"content_character": ["内容标签·人设", …最多4个], '
|
|
85
|
-
'"content_world": ["内容标签·世界观", …最多1个], '
|
|
86
|
-
'"protagonist_1": "主角名1(5字内)", '
|
|
87
|
-
'"protagonist_2": "主角名2(5字内,没有就空串)", "summary": "作品简介(500字内)"}')
|
|
88
|
-
|
|
89
|
-
QIMAO_SCHEMA = ('{"book_name": "作品名称(18字内)", "target_reader": "男生 或 女生", '
|
|
90
|
-
'"category_main": "一级分类(从该频道一级分类表选一个)", '
|
|
91
|
-
'"category_sub": "二级分类(必须是该一级下的二级)", '
|
|
92
|
-
'"tags_style": ["风格标签", 必选1-3个], '
|
|
93
|
-
'"tags_role": ["角色标签", 必选1-3个], '
|
|
94
|
-
'"tags_plot": ["情节标签", 必选1-3个], '
|
|
95
|
-
'"tags_bg": ["背景标签", 必选1-3个], '
|
|
96
|
-
'"protagonist_1": "主角名1(5字内)", '
|
|
97
|
-
'"protagonist_2": "主角名2(5字内,没有就空串)", "status": "连载中 或 已完结", '
|
|
98
|
-
'"summary": "作品简介(200字内)"}')
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _fq_tables(reader):
|
|
102
|
-
"""番茄按目标读者取对应选项表(男频/女频两套完全独立的分类与标签)。"""
|
|
103
|
-
from . import bookmeta_catalog as cat
|
|
104
|
-
if reader == "女频":
|
|
105
|
-
return (cat.FANQIE_FEMALE_CATEGORIES, cat.FANQIE_FEMALE_TAGS_THEME,
|
|
106
|
-
cat.FANQIE_FEMALE_TAGS_ROLE, cat.FANQIE_FEMALE_TAGS_PLOT)
|
|
107
|
-
return (cat.FANQIE_MALE_CATEGORIES, cat.FANQIE_MALE_TAGS_THEME,
|
|
108
|
-
cat.FANQIE_MALE_TAGS_ROLE, cat.FANQIE_MALE_TAGS_PLOT)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _fq_content_tables():
|
|
112
|
-
"""番茄内容标签四组(男女性共用):情节/情感/人设/世界观。"""
|
|
113
|
-
from . import bookmeta_catalog as cat
|
|
114
|
-
return (cat.FANQIE_CONTENT_PLOT, cat.FANQIE_CONTENT_EMOTION,
|
|
115
|
-
cat.FANQIE_CONTENT_CHARACTER, cat.FANQIE_CONTENT_WORLD)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _fq_options_block(goal):
|
|
119
|
-
"""番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与标签表。
|
|
120
|
-
|
|
121
|
-
2026-09-24 起首行加频道判定规则:素材明显属于另一频时 target_reader
|
|
122
|
-
如实填、分类留空(规整层按该频表校验并兜底补齐),防「注入男频表+
|
|
123
|
-
女频素材」时硬凑男频分类。"""
|
|
124
|
-
reader = "女频" if "女" in (goal or "") else "男频"
|
|
125
|
-
cats, theme, role, plot = _fq_tables(reader)
|
|
126
|
-
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
127
|
-
return ("先按素材判定目标读者(战争/军功/历史争霸/都市异能/系统→男频;"
|
|
128
|
-
"总裁豪门/宅斗宫斗/年代/婚恋情感线为主→女频),target_reader 如实填;"
|
|
129
|
-
"主分类从下方按目标读者 %s 预注入的表里选,素材明显属于另一频时"
|
|
130
|
-
"分类留空待系统按该频表补齐。主分类要贴世界观而非字面词——架空"
|
|
131
|
-
"王朝/军事/军功题材选历史古代或历史脑洞,西方奇幻仅限西式魔幻"
|
|
132
|
-
"世界观。\n"
|
|
133
|
-
"目标读者(%s)可用的主分类表(必选且只能选一个):%s\n"
|
|
134
|
-
"阅读标签三组各最多选 2 个——主题组:%s\n角色组:%s\n情节组(部分,选最贴合的):%s\n"
|
|
135
|
-
"内容标签四组——情节组(最多4个,部分):%s\n情感组(最多2个):%s\n"
|
|
136
|
-
"人设组(最多4个,部分):%s\n世界观组(最多1个):%s"
|
|
137
|
-
% (reader, reader, "、".join(cats), "、".join(theme), "、".join(role),
|
|
138
|
-
"、".join(plot[:50]), "、".join(c_plot[:60]), "、".join(c_emo),
|
|
139
|
-
"、".join(c_char[:60]), "、".join(c_world)))
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _qm_options_block(goal):
|
|
143
|
-
"""七猫提示词的真实选项注入:双频道级联 + 频道判定规则 + 跨频道禁令。
|
|
144
|
-
|
|
145
|
-
2026-09-24 前只按 goal 猜频道注入单频道表,但挡不住模型自带另一频道
|
|
146
|
-
的分类知识跨频道自造组合(男频军事文被配成 女生/古代言情/权谋天下,
|
|
147
|
-
级联还自洽)——必须双频道全量注入并明示判定规则。"""
|
|
148
|
-
from . import bookmeta_catalog as cat
|
|
149
|
-
cascades = ["%s频道(一级→二级):%s" % (ch, ";".join(
|
|
150
|
-
"%s(二级:%s)" % (m, "、".join(v)) for m, v in mains.items()))
|
|
151
|
-
for ch, mains in cat.QIMAO_CATS.items()]
|
|
152
|
-
pool = ["%s组(必选1-3个):%s" % (g, "、".join(t))
|
|
153
|
-
for g, t in cat.QIMAO_TAG_GROUPS.items()]
|
|
154
|
-
return ("先按素材判定目标读者频道:战争/军功/练兵/历史争霸/都市异能/"
|
|
155
|
-
"系统流/玄幻修仙→男生;总裁豪门/宅斗宫斗/年代/婚恋情感线为主→"
|
|
156
|
-
"女生。target_reader 必须填判定的频道,一级/二级分类只能从该频道"
|
|
157
|
-
"的级联表里选,严禁跨频道——如「权谋天下」只存在于女生·古代言情"
|
|
158
|
-
"下,男频军事历史文不得选用;「架空历史」属男生·历史。\n"
|
|
159
|
-
"%s\n作品标签四组(男女生同池):%s"
|
|
160
|
-
% ("。".join(cascades), ";".join(pool)))
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
# ---- 频道题材信号(2026-09-24 实案:男频军事文《戍边骑奴》被生成端配成
|
|
164
|
-
# 女生/古代言情/权谋天下——「权谋天下」字面贴合朝堂线但只存在于女生频道,
|
|
165
|
-
# 模型跨频道自造组合且级联自洽,目录校验拦不住,只能靠题材信号判频道。
|
|
166
|
-
# 必须保守:信号单侧命中才判,双侧命中/零命中一律不判(宁可不纠不可错纠)。
|
|
167
|
-
# 证据语料只用 goal+简介:分类字段是嫌疑输出、标签池男女同池,都不算证据。
|
|
168
|
-
_CH_SIGNAL_MALE = ("军功", "戍边", "骑奴", "特种兵", "练兵", "杀伐", "阵斩",
|
|
169
|
-
"百夫长", "大军", "蛮族", "边军", "边关", "边疆", "战场",
|
|
170
|
-
"争霸", "兵王", "战神", "赘婿", "武侠", "修仙", "修真",
|
|
171
|
-
"玄幻", "机甲", "星际", "电竞", "网游", "领主")
|
|
172
|
-
_CH_SIGNAL_FEMALE = ("言情", "总裁", "豪门", "宅斗", "宫斗", "嫡女", "庶女",
|
|
173
|
-
"甜宠", "虐恋", "宠妻", "宠夫", "王妃", "医妃", "皇后",
|
|
174
|
-
"公主", "丫鬟", "千金", "女配", "追妻", "闪婚", "隐婚",
|
|
175
|
-
"婆媳", "闺蜜", "女尊", "女强", "军婚", "宫闱")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _channel_anchor(text):
|
|
179
|
-
"""题材信号判频道:男/女信号词单侧命中返回 男生/女生,否则 None。"""
|
|
180
|
-
t = str(text or "")
|
|
181
|
-
if not t:
|
|
182
|
-
return None
|
|
183
|
-
male = any(w in t for w in _CH_SIGNAL_MALE)
|
|
184
|
-
female = any(w in t for w in _CH_SIGNAL_FEMALE)
|
|
185
|
-
if male and not female:
|
|
186
|
-
return "男生"
|
|
187
|
-
if female and not male:
|
|
188
|
-
return "女生"
|
|
189
|
-
return None
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
_READER_OF = {"fanqie": {"男生": "男频", "女生": "女频"},
|
|
193
|
-
"qimao": {"男频": "男生", "女频": "女生"}}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def _reader_anchor(task, platform, meta):
|
|
197
|
-
"""频道锚:题材信号(goal+简介)优先,其次同书另一平台已定的读者。
|
|
198
|
-
|
|
199
|
-
返回 (该平台的读者词, 依据说明) 或 (None, "")。"""
|
|
200
|
-
corpus = "。".join(x for x in ((task or {}).get("goal") or "",
|
|
201
|
-
(meta or {}).get("summary") or "") if x)
|
|
202
|
-
sig = _channel_anchor(corpus)
|
|
203
|
-
if sig:
|
|
204
|
-
# 信号词是男生/女生:七猫原样用,番茄翻成 男频/女频
|
|
205
|
-
own = sig if platform == "qimao" else {"男生": "男频", "女生": "女频"}[sig]
|
|
206
|
-
return own, "题材信号"
|
|
207
|
-
peer_plat = "qimao" if platform == "fanqie" else "fanqie"
|
|
208
|
-
peer = (((task or {}).get("book_meta") or {}).get(peer_plat) or {})
|
|
209
|
-
if peer.get("status") == "done":
|
|
210
|
-
mapped = _READER_OF[platform].get(
|
|
211
|
-
(peer.get("data") or {}).get("target_reader"))
|
|
212
|
-
if mapped:
|
|
213
|
-
return mapped, "同书另一平台(%s)已定读者" % PLATFORMS[peer_plat]["label"]
|
|
214
|
-
return None, ""
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _apply_fix_note(meta, source):
|
|
218
|
-
"""把 _fill_required_fields 记录的纠偏说明并进 source。
|
|
219
|
-
|
|
220
|
-
meta 里的 _fixes 是临时键,随出随清,不落 book_meta 数据(否则
|
|
221
|
-
repair_existing 的幂等比较会每次不等、启动反复重写)。"""
|
|
222
|
-
fixes = meta.pop("_fixes", None)
|
|
223
|
-
if fixes:
|
|
224
|
-
source = (str(source or "") + ";" + ";".join(str(x) for x in fixes)
|
|
225
|
-
).strip(";")
|
|
226
|
-
return source
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def _collect_material(task):
|
|
230
|
-
"""开书资料素材:目标 + 附件上下文 + 故事圣经 + 最近一次大纲 + 第一章开头。"""
|
|
231
|
-
from . import store # 惰性导入,同 planner:避免测试环境导入顺序问题
|
|
232
|
-
parts = ["## 小说目标\n" + (task.get("goal") or "(无)")]
|
|
233
|
-
ctx = (task.get("context") or "").strip()
|
|
234
|
-
if ctx:
|
|
235
|
-
parts.append("## 背景与上下文\n" + ctx[:2000])
|
|
236
|
-
_, bible, _ = store.read_story_bible(task["id"])
|
|
237
|
-
if bible:
|
|
238
|
-
parts.append("## 故事圣经\n" + bible[:6000])
|
|
239
|
-
outline = None
|
|
240
|
-
for r in store.task_runs(task["id"]):
|
|
241
|
-
o = r.get("outline")
|
|
242
|
-
if o and o.get("chapters") and not o.get("degraded"):
|
|
243
|
-
outline = o
|
|
244
|
-
break
|
|
245
|
-
if outline:
|
|
246
|
-
lines = ["书名:%s" % (outline.get("book_title") or "(未定)")]
|
|
247
|
-
for k, c in enumerate(outline["chapters"]):
|
|
248
|
-
lines.append("第 %d 章《%s》:%s" % (k + 1, c.get("title", ""),
|
|
249
|
-
str(c.get("beats") or "")[:120]))
|
|
250
|
-
parts.append("## 已有大纲\n" + "\n".join(lines))
|
|
251
|
-
head = ""
|
|
252
|
-
try:
|
|
253
|
-
from . import runner
|
|
254
|
-
# 章节文件命名并存:连载流水线落 chapter-01.md(两位),有的书是
|
|
255
|
-
# chapter-001.md(三位)——只认一种会瞎掉「第一章开头」素材
|
|
256
|
-
# (2026-09-28 马甲书案:明明 8 章成稿在手却报「没有正文」)。
|
|
257
|
-
for name in ("chapter-001.md", "chapter-01.md", "chapter-1.md"):
|
|
258
|
-
p = Path(task.get("workdir") or "") / name
|
|
259
|
-
if p.is_file():
|
|
260
|
-
head = runner.read_text_any_enc(p)[:_FIRST_CH_HEAD]
|
|
261
|
-
break
|
|
262
|
-
except OSError:
|
|
263
|
-
pass
|
|
264
|
-
if head:
|
|
265
|
-
parts.append("## 第一章开头\n" + head)
|
|
266
|
-
return "\n\n".join(parts), outline
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def _tags(v, limit=6):
|
|
270
|
-
"""(旧字段兜底保留)通用标签列表清洗:去重/去空/截断。"""
|
|
271
|
-
if not isinstance(v, list):
|
|
272
|
-
return []
|
|
273
|
-
out = []
|
|
274
|
-
for x in v:
|
|
275
|
-
s = str(x).strip()[:12]
|
|
276
|
-
if s and s not in out:
|
|
277
|
-
out.append(s)
|
|
278
|
-
if len(out) >= limit:
|
|
279
|
-
break
|
|
280
|
-
return out
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _name(v, limit):
|
|
284
|
-
return str(v or "").strip().replace("\n", " ")[:limit]
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def _summary(v):
|
|
288
|
-
"""剥 markdown 装饰后截断。2026-09-23 实案:简介混成调研报告标题
|
|
289
|
-
「# 网文选题分析报告…」——模型把研究素材当小说素材时输出的是标题行,
|
|
290
|
-
至少不能把 #/* 符号和报告腔标题原样带进建书表单。"""
|
|
291
|
-
t = re.sub(r"^\s*#{1,6}\s*", "", str(v or "").strip())
|
|
292
|
-
t = re.sub(r"(?m)^\s*[-*•]\s*", "", t)
|
|
293
|
-
t = t.replace("**", "").replace("__", "")
|
|
294
|
-
t = re.sub(r"\n{2,}", "\n", t).strip()
|
|
295
|
-
return t[:500]
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def _in_table(v, table, limit=10):
|
|
299
|
-
"""值必须在平台真实选项表内;不在返回空串(宁缺勿错——填了假选项用户还得手动改)。"""
|
|
300
|
-
s = _name(v, limit)
|
|
301
|
-
return s if s in (table or []) else ""
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def _filter_table(v, table, limit=6):
|
|
305
|
-
"""多选标签:只保留表内项(保持模型给的顺序),去重截断。"""
|
|
306
|
-
if not isinstance(v, list):
|
|
307
|
-
return []
|
|
308
|
-
out = []
|
|
309
|
-
for x in v:
|
|
310
|
-
s = str(x).strip()[:12]
|
|
311
|
-
if s and s in (table or []) and s not in out:
|
|
312
|
-
out.append(s)
|
|
313
|
-
if len(out) >= limit:
|
|
314
|
-
break
|
|
315
|
-
return out
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def _norm(data, platform, goal=""):
|
|
319
|
-
"""规范化平台字段输出;不合规返回 None。
|
|
320
|
-
|
|
321
|
-
分类/标签按平台真实选项表校验(bookmeta_catalog):分类单选不在表内清空、
|
|
322
|
-
标签多选只留表内项;七猫二级按级联校验(一级无级联记录时宽松放行)。"""
|
|
323
|
-
if not isinstance(data, dict):
|
|
324
|
-
return None
|
|
325
|
-
book_name = _name(data.get("book_name"), 15 if platform == "fanqie" else 15)
|
|
326
|
-
summary = _summary(data.get("summary"))
|
|
327
|
-
if not book_name and not summary:
|
|
328
|
-
return None
|
|
329
|
-
if platform == "fanqie":
|
|
330
|
-
reader = str(data.get("target_reader") or "").strip()
|
|
331
|
-
if reader not in ("男频", "女频"):
|
|
332
|
-
reader = "女频" if "女" in (goal or "") else "男频"
|
|
333
|
-
mode = str(data.get("signing_mode") or "").strip()
|
|
334
|
-
if mode not in FANQIE_MODES:
|
|
335
|
-
mode = "连载模式"
|
|
336
|
-
cats, theme, role, plot = _fq_tables(reader)
|
|
337
|
-
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
338
|
-
# 兼容旧字段:read_tags/content_tags 并入情节/主题组(老数据不丢)
|
|
339
|
-
legacy_read = data.get("read_tags") if isinstance(data.get("read_tags"), list) else []
|
|
340
|
-
return {"book_name": book_name, "signing_mode": mode, "target_reader": reader,
|
|
341
|
-
"category": _in_table(data.get("category"), cats),
|
|
342
|
-
"tags_theme": _filter_table(data.get("tags_theme"), theme, 2),
|
|
343
|
-
"tags_role": _filter_table(data.get("tags_role"), role, 2),
|
|
344
|
-
"tags_plot": _filter_table(data.get("tags_plot"), plot, 2),
|
|
345
|
-
"content_plot": _filter_table(data.get("content_plot"), c_plot, 4),
|
|
346
|
-
"content_emotion": _filter_table(data.get("content_emotion"), c_emo, 2),
|
|
347
|
-
"content_character": _filter_table(data.get("content_character"), c_char, 4),
|
|
348
|
-
"content_world": _filter_table(data.get("content_world"), c_world, 1),
|
|
349
|
-
"legacy_read_tags": _filter_table(legacy_read, plot + theme + role),
|
|
350
|
-
"protagonist_1": _name(data.get("protagonist_1"), 5),
|
|
351
|
-
"protagonist_2": _name(data.get("protagonist_2"), 5),
|
|
352
|
-
"summary": summary}
|
|
353
|
-
from . import bookmeta_catalog as cat
|
|
354
|
-
reader = str(data.get("target_reader") or "").strip()
|
|
355
|
-
if reader not in ("男生", "女生"):
|
|
356
|
-
reader = "女生" if "女" in (goal or "") else "男生"
|
|
357
|
-
status = str(data.get("status") or "").strip()
|
|
358
|
-
if status not in ("连载中", "已完结"):
|
|
359
|
-
status = "连载中"
|
|
360
|
-
channel = cat.QIMAO_CATS.get(reader) or {}
|
|
361
|
-
c_main = _in_table(data.get("category_main"), list(channel))
|
|
362
|
-
c_sub = _name(data.get("category_sub"), 10)
|
|
363
|
-
if c_sub and not (c_main and c_sub in (channel.get(c_main) or [])):
|
|
364
|
-
# 一级/二级不自洽(一级空、或二级不属于该频道——如女生专属的
|
|
365
|
-
# 「现实故事」配了男生频道):跨频道反查官方目录纠正,频道与
|
|
366
|
-
# 一级跟着二级走(表单级联的选项集以此为准);全目录都没有
|
|
367
|
-
# 才清空——表单选不出的词不硬填
|
|
368
|
-
loc = cat.qimao_locate_sub(c_sub)
|
|
369
|
-
if loc:
|
|
370
|
-
reader, c_main = loc
|
|
371
|
-
else:
|
|
372
|
-
c_sub = ""
|
|
373
|
-
# 作品标签四组(官方每组必选 1-3):逐组过滤 + 截到 3;老数据 tags 并入
|
|
374
|
-
legacy_tags = data.get("tags") if isinstance(data.get("tags"), list) else []
|
|
375
|
-
groups = {}
|
|
376
|
-
for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
|
|
377
|
-
("tags_plot", "情节"), ("tags_bg", "背景")):
|
|
378
|
-
got = _filter_table(data.get(key), cat.QIMAO_TAG_GROUPS[gname], 3)
|
|
379
|
-
if len(got) < 3 and legacy_tags:
|
|
380
|
-
for t in _filter_table(legacy_tags, cat.QIMAO_TAG_GROUPS[gname], 3):
|
|
381
|
-
if t not in got:
|
|
382
|
-
got.append(t)
|
|
383
|
-
if len(got) >= 3:
|
|
384
|
-
break
|
|
385
|
-
groups[key] = got
|
|
386
|
-
return {"book_name": book_name, "target_reader": reader,
|
|
387
|
-
"category_main": c_main, "category_sub": c_sub,
|
|
388
|
-
"tags_style": groups["tags_style"], "tags_role": groups["tags_role"],
|
|
389
|
-
"tags_plot": groups["tags_plot"], "tags_bg": groups["tags_bg"],
|
|
390
|
-
"protagonist_1": _name(data.get("protagonist_1"), 5),
|
|
391
|
-
"protagonist_2": _name(data.get("protagonist_2"), 5),
|
|
392
|
-
"status": status, "summary": summary}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def _template_meta(task, platform, outline):
|
|
396
|
-
"""兜底模板:只做素材搬运归纳(书名/简介来自大纲与目标),其余留空待补。
|
|
397
|
-
|
|
398
|
-
标签/分类尽力而为:扫描素材文本(goal/圣经/大纲书名)里恰好命中平台
|
|
399
|
-
选项表的词就带上——模板没有模型理解力,不做语义归纳,命中多少算多少,
|
|
400
|
-
其余留「待补充」让用户手选(宁缺勿错,不填表外词)。"""
|
|
401
|
-
goal = task.get("goal") or ""
|
|
402
|
-
book_name = str((outline or {}).get("book_title") or task.get("title") or "").strip()
|
|
403
|
-
summary = goal.splitlines()[0][:500] if goal.strip() else ""
|
|
404
|
-
if not summary:
|
|
405
|
-
summary = book_name or "(待补充)" # 全空素材也要有返回,_norm 才不会拒绝
|
|
406
|
-
material = _material_text(task, outline)
|
|
407
|
-
data = {"book_name": book_name, "summary": summary}
|
|
408
|
-
if platform == "fanqie":
|
|
409
|
-
reader = "女频" if "女" in goal else "男频"
|
|
410
|
-
cats, theme, role, plot = _fq_tables(reader)
|
|
411
|
-
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
412
|
-
data.update({"target_reader": reader,
|
|
413
|
-
"category": _scan_table(material, cats, 1),
|
|
414
|
-
"tags_theme": _scan_table(material, theme, 2),
|
|
415
|
-
"tags_role": _scan_table(material, role, 2),
|
|
416
|
-
"tags_plot": _scan_table(material, plot, 2),
|
|
417
|
-
"content_plot": _scan_table(material, c_plot, 4),
|
|
418
|
-
"content_emotion": _scan_table(material, c_emo, 2),
|
|
419
|
-
"content_character": _scan_table(material, c_char, 4),
|
|
420
|
-
"content_world": _scan_table(material, c_world, 1)})
|
|
421
|
-
else:
|
|
422
|
-
from . import bookmeta_catalog as cat
|
|
423
|
-
reader = "女生" if "女" in goal else "男生"
|
|
424
|
-
data["target_reader"] = reader
|
|
425
|
-
for main, subs in (cat.QIMAO_CATS.get(reader) or {}).items():
|
|
426
|
-
if main in material:
|
|
427
|
-
data["category_main"] = main
|
|
428
|
-
for s in subs: # 二级名撞素材即选(如「宫闱宅斗」)
|
|
429
|
-
if s in material:
|
|
430
|
-
data["category_sub"] = s
|
|
431
|
-
break
|
|
432
|
-
break
|
|
433
|
-
for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
|
|
434
|
-
("tags_plot", "情节"), ("tags_bg", "背景")):
|
|
435
|
-
data[key] = _scan_table(material, cat.QIMAO_TAG_GROUPS[gname], 3)
|
|
436
|
-
return _norm(data, platform, goal)
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def _scan_table(text, table, limit):
|
|
440
|
-
"""素材文本里命中的选项表词(按表序取,保证真实存在)。"""
|
|
441
|
-
if not text:
|
|
442
|
-
return []
|
|
443
|
-
out = []
|
|
444
|
-
for word in table:
|
|
445
|
-
if len(word) >= 2 and word in text:
|
|
446
|
-
out.append(word)
|
|
447
|
-
if len(out) >= limit:
|
|
448
|
-
break
|
|
449
|
-
return out
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
def _material_text(task, outline):
|
|
453
|
-
"""标签词面扫描用的素材文本:goal + 圣经 + 大纲书名(模板兜底同源)。"""
|
|
454
|
-
material = task.get("goal") or ""
|
|
455
|
-
try:
|
|
456
|
-
_, bible, _ = store_read_bible(task)
|
|
457
|
-
material += "\n" + (bible or "")
|
|
458
|
-
except Exception:
|
|
459
|
-
pass
|
|
460
|
-
if outline:
|
|
461
|
-
material += "\n" + str(outline.get("book_title") or "")
|
|
462
|
-
return material
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def _fill_tag_gaps(task, platform, meta, outline):
|
|
466
|
-
"""llm 归一化后仍为空的标签组:用素材词面扫描补一轮(命中多少算多少)。
|
|
467
|
-
|
|
468
|
-
模型偶尔整组漏给(实测 codex-cli 只填了内容标签前两组);空着用户就得
|
|
469
|
-
手选,扫描命中的是官方表内词,补上好过留白——已有的组不动,不覆盖模型。"""
|
|
470
|
-
material = _material_text(task, outline)
|
|
471
|
-
if not material:
|
|
472
|
-
return meta
|
|
473
|
-
goal = task.get("goal") or ""
|
|
474
|
-
if platform == "fanqie":
|
|
475
|
-
reader = meta.get("target_reader") or ("女频" if "女" in goal else "男频")
|
|
476
|
-
cats, theme, role, plot = _fq_tables(reader)
|
|
477
|
-
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
478
|
-
pairs = [("tags_theme", theme, 2), ("tags_role", role, 2), ("tags_plot", plot, 2),
|
|
479
|
-
("content_plot", c_plot, 4), ("content_emotion", c_emo, 2),
|
|
480
|
-
("content_character", c_char, 4), ("content_world", c_world, 1)]
|
|
481
|
-
else:
|
|
482
|
-
from . import bookmeta_catalog as cat
|
|
483
|
-
pairs = [(k, cat.QIMAO_TAG_GROUPS[g], 3) for k, g in
|
|
484
|
-
(("tags_style", "风格"), ("tags_role", "角色"),
|
|
485
|
-
("tags_plot", "情节"), ("tags_bg", "背景"))]
|
|
486
|
-
for key, table, limit in pairs:
|
|
487
|
-
if not meta.get(key):
|
|
488
|
-
meta[key] = _scan_table(material, table, limit)
|
|
489
|
-
return meta
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
def _material_names(material):
|
|
493
|
-
"""从圣经/大纲/章节里提取明确标注的人物名。
|
|
494
|
-
|
|
495
|
-
只接受带人物语义的短语(如“女主:阿禾”或“男主叫陆沉”),避免把
|
|
496
|
-
普通句子里的名词误当成主角。返回顺序去重后的最多两个名字。
|
|
497
|
-
"""
|
|
498
|
-
text = str(material or "")
|
|
499
|
-
out = []
|
|
500
|
-
patterns = (
|
|
501
|
-
r"(?:主角|主人公|男主(?:角)?|女主(?:角)?|男一|女一|姓名|名字)"
|
|
502
|
-
r"\s*(?:是|叫|为|:|:)\s*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z·]{1,7})",
|
|
503
|
-
r"人物\s*[::]\s*([^\n。;;]{2,40})",
|
|
504
|
-
)
|
|
505
|
-
for pat in patterns:
|
|
506
|
-
for m in re.finditer(pat, text):
|
|
507
|
-
raw = m.group(1)
|
|
508
|
-
chunks = re.split(r"[、,,/和及与]|\s+", raw)
|
|
509
|
-
for chunk in chunks:
|
|
510
|
-
name = re.sub(r"[^\u4e00-\u9fffA-Za-z·]", "", chunk)
|
|
511
|
-
if 2 <= len(name) <= 8 and name not in out:
|
|
512
|
-
out.append(name[:5])
|
|
513
|
-
if len(out) >= 2:
|
|
514
|
-
return out
|
|
515
|
-
return out
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
def _pick_option(material, table, keywords=()):
|
|
519
|
-
"""从官方选项表中按素材命中词选择一个,完全命中不到时用首项。"""
|
|
520
|
-
text = str(material or "")
|
|
521
|
-
for item in table:
|
|
522
|
-
if len(item) >= 2 and item in text:
|
|
523
|
-
return item
|
|
524
|
-
for key, item in keywords:
|
|
525
|
-
if key and key in text and item in table:
|
|
526
|
-
return item
|
|
527
|
-
return table[0] if table else ""
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
def _has_value(value):
|
|
531
|
-
"""判断建书字段是否真正有值(兼容列表和历史占位文本)。"""
|
|
532
|
-
placeholders = {"待补充", "(待补充)", "(待补充)"}
|
|
533
|
-
if isinstance(value, list):
|
|
534
|
-
return any((text := str(x or "").strip()) and text not in placeholders
|
|
535
|
-
for x in value)
|
|
536
|
-
text = str(value or "").strip()
|
|
537
|
-
return bool(text) and text not in placeholders
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
def _md_plain(text):
|
|
541
|
-
"""markdown 轻洗(在 _summary 净化之上):每行剥标题/列表/引用符与行内
|
|
542
|
-
强调符,合并成一段正文文字(建书表单简介是单行框)。"""
|
|
543
|
-
out = []
|
|
544
|
-
for ln in _summary(text).splitlines():
|
|
545
|
-
ln = re.sub(r"^\s*(#{1,6}\s*|[-*+]\s+|>\s*|\d+[.、))]\s*)", "", ln)
|
|
546
|
-
ln = re.sub(r"[*_`#>]+", "", ln).strip()
|
|
547
|
-
if ln:
|
|
548
|
-
out.append(ln)
|
|
549
|
-
return "".join(out)
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
def _summary_ok(value):
|
|
553
|
-
"""简介够不够格进建书表单:番茄表单硬性要求 50-500 字(占位文案即
|
|
554
|
-
「请输入50-500字」),markdown 标题/占位符过不了平台提交校验,且自动
|
|
555
|
-
化流程看不到平台的字段报错,只会误报成「未登录或改版」。"""
|
|
556
|
-
return 50 <= len(_md_plain(value)) <= 500
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
def _prose_summary(cur, material):
|
|
560
|
-
"""简介兜底清理:markdown 洗成正文;洗完仍不足 50 字就从素材正文取。
|
|
561
|
-
取不出够长的正文时原样返回——由建书前置闸拦停并提示补写,不硬编。"""
|
|
562
|
-
text = _md_plain(cur)
|
|
563
|
-
if len(text) >= 50:
|
|
564
|
-
return text[:500]
|
|
565
|
-
body = _md_plain(material)
|
|
566
|
-
if len(body) >= 50:
|
|
567
|
-
return body[:500]
|
|
568
|
-
return str(cur or "").strip()
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def _fill_required_fields(task, platform, meta, outline, material):
|
|
572
|
-
"""生成阶段的必填字段闸门。
|
|
573
|
-
|
|
574
|
-
``_norm`` 仍保持“表外值清空”的纯校验语义,供旧数据修复和单测使用;
|
|
575
|
-
这里仅在真正交付作品信息前补齐平台建书表单的必填项,避免 UI 展示一整
|
|
576
|
-
屏“待补充”而无法直接复制建书。所有分类/标签仍来自官方目录。
|
|
577
|
-
"""
|
|
578
|
-
from . import bookmeta_catalog as cat
|
|
579
|
-
|
|
580
|
-
material = str(material or "")
|
|
581
|
-
names = _material_names(material)
|
|
582
|
-
female_words = ("女频", "女生", "女主", "言情", "古言", "恋爱")
|
|
583
|
-
female = any(x in ((task.get("goal") or "") + material) for x in female_words)
|
|
584
|
-
defaults = ("沈知微", "顾砚川", "林知夏") if female else ("陆沉", "苏晚", "程野")
|
|
585
|
-
if not _has_value(meta.get("protagonist_1")):
|
|
586
|
-
meta["protagonist_1"] = names[0] if names else defaults[0]
|
|
587
|
-
if not _has_value(meta.get("protagonist_2")):
|
|
588
|
-
candidate = names[1] if len(names) > 1 else defaults[1]
|
|
589
|
-
if candidate == meta["protagonist_1"]:
|
|
590
|
-
candidate = next((name for name in defaults if name != meta["protagonist_1"]), defaults[2])
|
|
591
|
-
meta["protagonist_2"] = candidate
|
|
592
|
-
|
|
593
|
-
# 简介质量:平台建书表单对简介有硬字数校验(番茄 50-500 字),markdown
|
|
594
|
-
# 标题/占位文本过不了提交且流程看不到报错——洗成正文,不够长从素材取
|
|
595
|
-
meta["summary"] = _prose_summary(meta.get("summary"), material)
|
|
596
|
-
|
|
597
|
-
# 频道锚:题材信号/同书另一平台的读者与模型声称的频道冲突时以锚为准
|
|
598
|
-
# 翻转(2026-09-24 实案:男频军事文被配成 女生/古代言情/权谋天下,
|
|
599
|
-
# 级联自洽骗过目录校验)。翻转后旧频道分类作废,由下方素材补齐逻辑
|
|
600
|
-
# 按正确频道重选;纠偏说明记 _fixes 临时键,由调用方并进 source。
|
|
601
|
-
anchor, why = _reader_anchor(task, platform, meta)
|
|
602
|
-
if anchor and meta.get("target_reader") != anchor:
|
|
603
|
-
meta["_fixes"] = ["频道纠偏:%s→%s(依据:%s)"
|
|
604
|
-
% (meta.get("target_reader") or "未填", anchor, why)]
|
|
605
|
-
meta["target_reader"] = anchor
|
|
606
|
-
if platform == "qimao":
|
|
607
|
-
ch = cat.QIMAO_CATS.get(anchor) or {}
|
|
608
|
-
if meta.get("category_main") not in ch:
|
|
609
|
-
meta["category_main"] = ""
|
|
610
|
-
ch_main = meta.get("category_main")
|
|
611
|
-
if meta.get("category_sub") and not (
|
|
612
|
-
ch_main and meta["category_sub"] in (ch.get(ch_main) or [])):
|
|
613
|
-
meta["category_sub"] = ""
|
|
614
|
-
else:
|
|
615
|
-
fq_cats, _, _, _ = _fq_tables(anchor)
|
|
616
|
-
if meta.get("category") not in fq_cats:
|
|
617
|
-
meta["category"] = ""
|
|
618
|
-
|
|
619
|
-
if platform == "fanqie":
|
|
620
|
-
reader = meta.get("target_reader") or ("女频" if female else "男频")
|
|
621
|
-
if reader not in ("男频", "女频"):
|
|
622
|
-
reader = "女频" if female else "男频"
|
|
623
|
-
meta["target_reader"] = reader
|
|
624
|
-
cats, theme, role, plot = _fq_tables(reader)
|
|
625
|
-
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
626
|
-
if meta.get("category") not in cats:
|
|
627
|
-
# 关键词兜底按频道给(2026-09-24 实案《戍边骑奴》:旧表不分频道,
|
|
628
|
-
# 「戍边→古风世情」是女频词,男频素材永远选不中,全部落到男频表
|
|
629
|
-
# 首项「西方奇幻」——架空王朝军事文被配成西式魔幻)
|
|
630
|
-
kw = (("历史", "历史古代"), ("古代", "历史古代"),
|
|
631
|
-
("王朝", "历史脑洞"), ("架空", "历史脑洞"),
|
|
632
|
-
("戍边", "历史古代"), ("边关", "历史古代"),
|
|
633
|
-
("抗战", "抗战谍战"), ("谍战", "抗战谍战"),
|
|
634
|
-
("军", "历史古代"), ("种田", "都市种田"),
|
|
635
|
-
("都市", "都市脑洞"), ("悬疑", "悬疑脑洞")) if reader == "男频" else (
|
|
636
|
-
("宅斗", "宫斗宅斗"), ("宫斗", "宫斗宅斗"),
|
|
637
|
-
("总裁", "豪门总裁"), ("豪门", "豪门总裁"),
|
|
638
|
-
("古代", "古风世情"), ("古言", "古言脑洞"),
|
|
639
|
-
("年代", "年代"), ("种田", "种田"),
|
|
640
|
-
("婚姻", "职场婚恋"), ("恋爱", "职场婚恋"),
|
|
641
|
-
("悬疑", "女频悬疑"), ("快穿", "快穿"))
|
|
642
|
-
meta["category"] = _pick_option(material, cats, kw)
|
|
643
|
-
groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
|
|
644
|
-
("tags_plot", plot, 2), ("content_plot", c_plot, 4),
|
|
645
|
-
("content_emotion", c_emo, 2), ("content_character", c_char, 4),
|
|
646
|
-
("content_world", c_world, 1))
|
|
647
|
-
else:
|
|
648
|
-
reader = meta.get("target_reader") or ("女生" if female else "男生")
|
|
649
|
-
if reader not in ("男生", "女生"):
|
|
650
|
-
reader = "女生" if female else "男生"
|
|
651
|
-
meta["target_reader"] = reader
|
|
652
|
-
channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
|
|
653
|
-
main = meta.get("category_main")
|
|
654
|
-
if main not in channel:
|
|
655
|
-
# 关键词兜底按频道给(2026-09-24 频道锚落地后读方可信,旧的
|
|
656
|
-
# 「戍边→古代言情」在男生频道下永远选不中白占位)
|
|
657
|
-
kw = (("戍边", "历史"), ("边关", "历史"), ("军", "军事"),
|
|
658
|
-
("都市", "都市"), ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")
|
|
659
|
-
) if reader == "男生" else (
|
|
660
|
-
("宅斗", "古代言情"), ("宫斗", "古代言情"),
|
|
661
|
-
("古代", "古代言情"), ("总裁", "现代言情"),
|
|
662
|
-
("年代", "现代言情"), ("都市", "现代言情"))
|
|
663
|
-
main = _pick_option(material, list(channel), kw)
|
|
664
|
-
meta["category_main"] = main
|
|
665
|
-
subs = channel.get(main) or []
|
|
666
|
-
sub = meta.get("category_sub")
|
|
667
|
-
if sub not in subs:
|
|
668
|
-
located = cat.qimao_locate_sub(sub)
|
|
669
|
-
if located:
|
|
670
|
-
reader, main = located
|
|
671
|
-
meta["target_reader"] = reader
|
|
672
|
-
meta["category_main"] = main
|
|
673
|
-
subs = cat.QIMAO_CATS[reader][main]
|
|
674
|
-
else:
|
|
675
|
-
sub = ""
|
|
676
|
-
if sub not in subs:
|
|
677
|
-
meta["category_sub"] = _pick_option(material, subs)
|
|
678
|
-
groups = (("tags_style", cat.QIMAO_TAG_GROUPS["风格"], 3),
|
|
679
|
-
("tags_role", cat.QIMAO_TAG_GROUPS["角色"], 3),
|
|
680
|
-
("tags_plot", cat.QIMAO_TAG_GROUPS["情节"], 3),
|
|
681
|
-
("tags_bg", cat.QIMAO_TAG_GROUPS["背景"], 3))
|
|
682
|
-
for key, table, limit in groups:
|
|
683
|
-
values = meta.get(key)
|
|
684
|
-
if not isinstance(values, list):
|
|
685
|
-
values = []
|
|
686
|
-
values = [str(v).strip() for v in values if str(v or "").strip() in table]
|
|
687
|
-
if not values:
|
|
688
|
-
values = [_pick_option(material, table)]
|
|
689
|
-
meta[key] = values[:limit]
|
|
690
|
-
return meta
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
def store_read_bible(task):
|
|
694
|
-
"""圣经读取的小包装(模板兜底用;异常向上抛由调用方吞掉)。"""
|
|
695
|
-
from . import store
|
|
696
|
-
fp, text, err = store.read_story_bible(task.get("id") or "")
|
|
697
|
-
if err:
|
|
698
|
-
raise ValueError(err)
|
|
699
|
-
return fp, text, None
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
def _resolve_author(task):
|
|
703
|
-
"""作者 CLI 兜底用:按「实现」角色选一个 real 智能体(同连载起草的路由口径)。"""
|
|
704
|
-
try:
|
|
705
|
-
from . import catalog, manager, registry, router
|
|
706
|
-
agents = registry.effective_agents(catalog.load(), manager.detect_all())
|
|
707
|
-
agent, _ = router.pick([a for a in agents if a.get("mode") == "real"],
|
|
708
|
-
"implement", task.get("type") or "serial_novel")
|
|
709
|
-
return agent
|
|
710
|
-
except Exception:
|
|
711
|
-
return None
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
def make_book_meta(task, platform, author_agent=None, log_path=None):
|
|
715
|
-
"""生成一个平台的作品信息。返回 (meta dict, source 字符串);模板兜底永远有返回。"""
|
|
716
|
-
from . import knowledge, modelhub, planner, runner, skills # 惰性导入,同 planner
|
|
717
|
-
material, outline = _collect_material(task)
|
|
718
|
-
# 素材充分性门:没有大纲也没有章稿/圣经的任务(如「帮我定方向」的选题
|
|
719
|
-
# 研究任务)没有可推介的小说要素——生成只会产出报告腔垃圾
|
|
720
|
-
# (2026-09-23 实案:简介=「# 网文选题分析报告…」)。宁明确拒绝。
|
|
721
|
-
if not outline and "## 第一章开头" not in material and "## 故事圣经" not in material:
|
|
722
|
-
raise ValueError(
|
|
723
|
-
"该任务还没有小说大纲、正文或故事圣经,无法生成作品信息——"
|
|
724
|
-
"先完成大纲/首批章节后再来一键生成")
|
|
725
|
-
schema = {"fanqie": FANQIE_SCHEMA, "qimao": QIMAO_SCHEMA}.get(platform) or ""
|
|
726
|
-
plat_label = PLATFORMS[platform]["label"]
|
|
727
|
-
sk_block, _ = skills.block_for(task)
|
|
728
|
-
# 实案教训(2026-09-23):素材偏研究向时模型把简介写成「分析报告标题」
|
|
729
|
-
summary_rule = ("- summary 必须是面向读者的小说推介文案:点出题材、主角、核心冲突与悬念,"
|
|
730
|
-
"200/500 字内成段;绝不允许写成报告标题、要点清单或任务说明。")
|
|
731
|
-
kb_block = knowledge.block_for(task)
|
|
732
|
-
if kb_block:
|
|
733
|
-
sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
|
|
734
|
-
goal = task.get("goal") or ""
|
|
735
|
-
# 真实选项注入:分类/标签表按平台与读者频段给全(模型只能从表里选)
|
|
736
|
-
options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block(goal))
|
|
737
|
-
prompt = (PROMPT_HEAD.replace("__PLATFORM__", plat_label)
|
|
738
|
-
.replace("__SKILLS__", sk_block)
|
|
739
|
-
.replace("__SCHEMA__", schema)
|
|
740
|
-
.replace("__OPTIONS_RULE__", options + "\n" + summary_rule)
|
|
741
|
-
+ "\n\n## 小说素材\n" + material)
|
|
742
|
-
|
|
743
|
-
orch = None
|
|
744
|
-
if not template_only():
|
|
745
|
-
try:
|
|
746
|
-
orch = modelhub.resolve_orchestrator()
|
|
747
|
-
except Exception:
|
|
748
|
-
orch = None
|
|
749
|
-
orch_err = ""
|
|
750
|
-
if orch:
|
|
751
|
-
prov, model = orch
|
|
752
|
-
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
753
|
-
planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
|
|
754
|
-
provider=prov.get("name", prov.get("id", "")),
|
|
755
|
-
provider_id=prov.get("id", ""))
|
|
756
|
-
if res["ok"]:
|
|
757
|
-
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
758
|
-
if meta:
|
|
759
|
-
meta = _fill_tag_gaps(task, platform, meta, outline)
|
|
760
|
-
meta = _fill_required_fields(task, platform, meta, outline, material)
|
|
761
|
-
return meta, _apply_fix_note(meta, "编排者(%s · %s)"
|
|
762
|
-
% (prov.get("name", prov["id"]), model))
|
|
763
|
-
orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
|
|
764
|
-
|
|
765
|
-
if author_agent and author_agent.get("mode") == "real" and not template_only():
|
|
766
|
-
res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
|
|
767
|
-
workdir=task.get("workdir"), readonly=True,
|
|
768
|
-
timeout=BOOKMETA_TIMEOUT, log_path=log_path)
|
|
769
|
-
planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
|
|
770
|
-
if res["ok"]:
|
|
771
|
-
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
772
|
-
if meta:
|
|
773
|
-
meta = _fill_tag_gaps(task, platform, meta, outline)
|
|
774
|
-
meta = _fill_required_fields(task, platform, meta, outline, material)
|
|
775
|
-
return meta, _apply_fix_note(meta, "llm(%s)" % author_agent["id"])
|
|
776
|
-
orch_err = orch_err or str(res.get("error") or "")[:200]
|
|
777
|
-
|
|
778
|
-
meta = _template_meta(task, platform, outline)
|
|
779
|
-
meta = _fill_required_fields(task, platform, meta, outline, material)
|
|
780
|
-
meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
|
|
781
|
-
return meta, _apply_fix_note(meta, meta["source"])
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
# ---- 换名(2026-10-02 用户实案:生成的书名与本站已有作品重名,用户要的是
|
|
785
|
-
# 「换一个名字」而不是整套重摇——重摇会覆盖已核对过的分类/标签/简介)
|
|
786
|
-
|
|
787
|
-
RENAME_TIMEOUT = 120
|
|
788
|
-
# 排除名单注入上限:全站书名可能上百个,超出的按序丢弃(提示词预算保护;
|
|
789
|
-
# 排序后字母序前 80 个 + 调用方传入的近期撞名足够防撞,撞了还有重试兜底)
|
|
790
|
-
RENAME_EXCLUDE_LIMIT = 80
|
|
791
|
-
|
|
792
|
-
RENAME_PROMPT = """你是网文编辑。这本书《__OLD__》的书名与已有作品重名了,请另起一个新书名。
|
|
793
|
-
|
|
794
|
-
书的基本情况:
|
|
795
|
-
__MATERIAL__
|
|
796
|
-
|
|
797
|
-
硬性要求:
|
|
798
|
-
- 只输出一个 ```json 代码块:{"book_name": "新书名"},不要输出其他内容;
|
|
799
|
-
- 新书名不超过 15 个字,贴合题材、有网文点击欲,与旧书名同一题材但用词明显不同;
|
|
800
|
-
- 严禁与下面任何一本书重名,也不得只加「2」「新」「之」等后缀敷衍:
|
|
801
|
-
__EXCLUDE__"""
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def used_book_names():
|
|
805
|
-
"""本站全部已用书名(换名排除名单):已建书登记(books.json 的 title)
|
|
806
|
-
+ 所有任务 book_meta 各平台 done 态的 book_name。读取异常只降级不阻断
|
|
807
|
-
(名单不全最坏结果是换出来的名字仍撞,用户再点一次)。"""
|
|
808
|
-
from .publish import ledger
|
|
809
|
-
names = set()
|
|
810
|
-
try:
|
|
811
|
-
# books.json 结构:{task_id: {platform: {book_id, title, url, ...}}}
|
|
812
|
-
for by_plat in (ledger.load_books() or {}).values():
|
|
813
|
-
if not isinstance(by_plat, dict):
|
|
814
|
-
continue
|
|
815
|
-
for rec in by_plat.values():
|
|
816
|
-
t = str((rec or {}).get("title") or "").strip()
|
|
817
|
-
if t:
|
|
818
|
-
names.add(t)
|
|
819
|
-
except Exception:
|
|
820
|
-
pass
|
|
821
|
-
try:
|
|
822
|
-
from . import store
|
|
823
|
-
for task in store.list_tasks(limit=10 ** 9):
|
|
824
|
-
for entry in (task.get("book_meta") or {}).values():
|
|
825
|
-
if isinstance(entry, dict) and entry.get("status") == "done":
|
|
826
|
-
n = str((entry.get("data") or {}).get("book_name") or "").strip()
|
|
827
|
-
if n:
|
|
828
|
-
names.add(n)
|
|
829
|
-
except Exception:
|
|
830
|
-
pass
|
|
831
|
-
return names
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
def rename_book(task, platform, old_name, exclude_names=()):
|
|
835
|
-
"""给已生成的作品信息换一个不重名的书名。返回 (new_name, "") 或 ("", 错误)。
|
|
836
|
-
|
|
837
|
-
只产出新名,不写盘——调用方负责更新 book_meta 与归档同步;这样模型失败
|
|
838
|
-
时现有数据原样保留(区别于整套生成的线程+状态机,轻调用同步返回即可)。
|
|
839
|
-
新名若仍撞排除名单,把撞名项并入名单重试一次。"""
|
|
840
|
-
from . import modelhub, planner, runner
|
|
841
|
-
old_name = str(old_name or "").strip()
|
|
842
|
-
exclude = {str(x).strip() for x in (exclude_names or ()) if str(x).strip()}
|
|
843
|
-
exclude.add(old_name)
|
|
844
|
-
goal_lines = [x.strip() for x in (task.get("goal") or "").splitlines() if x.strip()]
|
|
845
|
-
meta_lines = []
|
|
846
|
-
if goal_lines:
|
|
847
|
-
meta_lines.append("目标:" + goal_lines[0][:200])
|
|
848
|
-
try:
|
|
849
|
-
_, bible, _ = store_read_bible(task)
|
|
850
|
-
if bible:
|
|
851
|
-
meta_lines.append("故事圣经摘录:" + bible[:400])
|
|
852
|
-
except Exception:
|
|
853
|
-
pass
|
|
854
|
-
material = "\n".join(meta_lines) if meta_lines else "(素材缺失,按旧书名题材另起)"
|
|
855
|
-
orch = None
|
|
856
|
-
try:
|
|
857
|
-
orch = modelhub.resolve_orchestrator()
|
|
858
|
-
except Exception:
|
|
859
|
-
orch = None
|
|
860
|
-
if not orch:
|
|
861
|
-
return "", "编排者模型不可用,无法自动换名(可稍后重试)"
|
|
862
|
-
prov, model = orch
|
|
863
|
-
last = ""
|
|
864
|
-
for _ in range(2): # 首轮撞名单 → 带撞名项重试一次
|
|
865
|
-
ex_block = "\n".join("- " + n for n in sorted(exclude)[:RENAME_EXCLUDE_LIMIT])
|
|
866
|
-
prompt = (RENAME_PROMPT.replace("__OLD__", old_name)
|
|
867
|
-
.replace("__MATERIAL__", material)
|
|
868
|
-
.replace("__EXCLUDE__", ex_block or "(暂无)"))
|
|
869
|
-
# max_tokens 4000 对齐主生成(2026-10-03 实案:glm-5.3 思考计入
|
|
870
|
-
# max_tokens,给 400 三次全被思考烧穿 output 打满,JSON 没成形)
|
|
871
|
-
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000,
|
|
872
|
-
timeout=RENAME_TIMEOUT)
|
|
873
|
-
planner._log_usage("bookmeta", "bookmeta-rename", task, res, model=model,
|
|
874
|
-
provider=prov.get("name", prov.get("id", "")),
|
|
875
|
-
provider_id=prov.get("id", ""))
|
|
876
|
-
if not res["ok"]:
|
|
877
|
-
last = str(res.get("error") or "模型调用失败")[:200]
|
|
878
|
-
continue
|
|
879
|
-
text = str(res.get("text") or "")
|
|
880
|
-
data = runner.extract_json(text)
|
|
881
|
-
new_name = _name(data.get("book_name") if isinstance(data, dict) else "", 15)
|
|
882
|
-
if not new_name:
|
|
883
|
-
# 兜底:思考烧预算时 JSON 可能没成形,正文里有《书名》/「书名」
|
|
884
|
-
# 形态就抠出来用——抠出的名字仍要过下面的排除名单检查
|
|
885
|
-
m = re.search(r"[《「]([^《》「」]{1,15})[》」]", text)
|
|
886
|
-
if m:
|
|
887
|
-
new_name = _name(m.group(1), 15)
|
|
888
|
-
if not new_name:
|
|
889
|
-
last = "返回内容无法解析出新书名"
|
|
890
|
-
continue
|
|
891
|
-
if new_name in exclude:
|
|
892
|
-
last = "新书名《%s》仍与本站已有作品重名" % new_name
|
|
893
|
-
exclude.add(new_name)
|
|
894
|
-
continue
|
|
895
|
-
return new_name, ""
|
|
896
|
-
return "", last or "换名失败,请稍后重试"
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
def render_markdown(task, platform, meta):
|
|
900
|
-
"""落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
|
|
901
|
-
plat = PLATFORMS[platform]["label"]
|
|
902
|
-
lines = ["# 作品信息(%s)" % plat, "",
|
|
903
|
-
"> 任务:%s 生成于 %s 来源:%s" % (
|
|
904
|
-
task.get("title") or task.get("id") or "",
|
|
905
|
-
time.strftime("%Y-%m-%d %H:%M"), meta.get("source") or "")]
|
|
906
|
-
lines.append("")
|
|
907
|
-
for key, label in FIELD_LABELS[platform]:
|
|
908
|
-
v = meta.get(key)
|
|
909
|
-
if isinstance(v, list):
|
|
910
|
-
v = "、".join(v) if v else "(待补充)"
|
|
911
|
-
elif key == "summary":
|
|
912
|
-
v = (v or "").strip() or "(待补充)"
|
|
913
|
-
else:
|
|
914
|
-
v = str(v or "").strip() or "(待补充)"
|
|
915
|
-
lines.append("- **%s**:%s" % (label, v))
|
|
916
|
-
lines.append("")
|
|
917
|
-
lines.append("> 各字段可直接复制进 %s 建书表单;分类与标签均选自平台真实选项表。" % plat)
|
|
918
|
-
return "\n".join(lines)
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
def generate_async(task_id, platform, author_agent=None):
|
|
922
|
-
"""后台线程入口:跑生成并把终态写回任务 book_meta;成功时落工作目录归档。
|
|
923
|
-
|
|
924
|
-
状态机:running(路由置入)→ done / failed。任何异常都落 failed,不留悬挂。"""
|
|
925
|
-
from . import store
|
|
926
|
-
task = store.get_task(task_id)
|
|
927
|
-
if not task:
|
|
928
|
-
return
|
|
929
|
-
try:
|
|
930
|
-
meta, source = make_book_meta(task, platform, author_agent=author_agent)
|
|
931
|
-
entry = {"status": "done", "data": meta, "source": source,
|
|
932
|
-
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
933
|
-
fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
|
|
934
|
-
if str(fp.parent) and fp.parent.is_dir():
|
|
935
|
-
fp.write_text(render_markdown(task, platform, meta), encoding="utf-8")
|
|
936
|
-
entry["file"] = PLATFORMS[platform]["file"]
|
|
937
|
-
except Exception as e: # 兜底线程不能静默死掉
|
|
938
|
-
entry = {"status": "failed", "error": str(e)[:300],
|
|
939
|
-
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
940
|
-
cur = store.get_task(task_id)
|
|
941
|
-
if not cur:
|
|
942
|
-
return
|
|
943
|
-
prev = (cur.get("book_meta") or {}).get(platform) or {}
|
|
944
|
-
if prev.get("status") != "running": # 期间被删/被重置:丢弃结果
|
|
945
|
-
return
|
|
946
|
-
store.set_book_meta(task_id, platform, entry)
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
def recover_orphans():
|
|
950
|
-
"""启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
|
|
951
|
-
前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
|
|
952
|
-
遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
|
|
953
|
-
from . import store
|
|
954
|
-
n = 0
|
|
955
|
-
for task in store.list_tasks(limit=10 ** 9):
|
|
956
|
-
for plat, entry in (task.get("book_meta") or {}).items():
|
|
957
|
-
if isinstance(entry, dict) and entry.get("status") == "running":
|
|
958
|
-
store.set_book_meta(task["id"], plat, {
|
|
959
|
-
"status": "failed",
|
|
960
|
-
"error": "生成随服务重启中断,请点重试",
|
|
961
|
-
"at": time.strftime("%Y-%m-%d %H:%M:%S")})
|
|
962
|
-
n += 1
|
|
963
|
-
return n
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
def repair_existing():
|
|
967
|
-
"""补齐历史已生成记录中的必填字段。
|
|
968
|
-
|
|
969
|
-
旧版本允许分类/主角为空并把结果标成 done;只要字段缺失就按当前官方
|
|
970
|
-
目录和已有素材补一次,已有非空字段保持不变。返回修复条数,供启动日志
|
|
971
|
-
和升级诊断使用。
|
|
972
|
-
"""
|
|
973
|
-
from . import store
|
|
974
|
-
repaired = 0
|
|
975
|
-
for task in store.list_tasks(limit=10 ** 9):
|
|
976
|
-
book_meta = task.get("book_meta") or {}
|
|
977
|
-
for platform in PLATFORMS:
|
|
978
|
-
entry = book_meta.get(platform) or {}
|
|
979
|
-
if entry.get("status") != "done" or not isinstance(entry.get("data"), dict):
|
|
980
|
-
continue
|
|
981
|
-
data = dict(entry["data"])
|
|
982
|
-
if platform == "fanqie":
|
|
983
|
-
reader = data.get("target_reader")
|
|
984
|
-
cats, _, _, _ = _fq_tables(reader if reader in ("男频", "女频") else "男频")
|
|
985
|
-
required_ok = (
|
|
986
|
-
data.get("category") in cats
|
|
987
|
-
and all(_has_value(data.get(key)) for key in (
|
|
988
|
-
"tags_theme", "tags_role", "tags_plot", "content_plot",
|
|
989
|
-
"content_emotion", "content_character", "content_world"))
|
|
990
|
-
and _has_value(data.get("protagonist_1"))
|
|
991
|
-
and _has_value(data.get("protagonist_2"))
|
|
992
|
-
and _summary_ok(data.get("summary")))
|
|
993
|
-
else:
|
|
994
|
-
from . import bookmeta_catalog as cat
|
|
995
|
-
reader = data.get("target_reader")
|
|
996
|
-
channel = cat.QIMAO_CATS.get(reader) or {}
|
|
997
|
-
main = data.get("category_main")
|
|
998
|
-
required_ok = (
|
|
999
|
-
main in channel
|
|
1000
|
-
and data.get("category_sub") in (channel.get(main) or [])
|
|
1001
|
-
and all(_has_value(data.get(key)) for key in (
|
|
1002
|
-
"tags_style", "tags_role", "tags_plot", "tags_bg"))
|
|
1003
|
-
and _has_value(data.get("protagonist_1"))
|
|
1004
|
-
and _has_value(data.get("protagonist_2"))
|
|
1005
|
-
and _summary_ok(data.get("summary")))
|
|
1006
|
-
# 频道失配也视为待修复(2026-09-24 实案:级联自洽的跨频道组合
|
|
1007
|
-
# 字段全齐,required_ok 挡不住,只能靠题材信号识别)
|
|
1008
|
-
anchor, _ = _reader_anchor(task, platform,
|
|
1009
|
-
{"summary": data.get("summary") or ""})
|
|
1010
|
-
if anchor and anchor != data.get("target_reader"):
|
|
1011
|
-
required_ok = False
|
|
1012
|
-
if required_ok:
|
|
1013
|
-
continue
|
|
1014
|
-
try:
|
|
1015
|
-
material, outline = _collect_material(task)
|
|
1016
|
-
fixed = _fill_required_fields(task, platform, data, outline, material)
|
|
1017
|
-
fixes = fixed.pop("_fixes", None) # 先清临时键再比幂等
|
|
1018
|
-
if fixed == entry["data"]:
|
|
1019
|
-
continue
|
|
1020
|
-
payload = dict(entry)
|
|
1021
|
-
payload["data"] = fixed
|
|
1022
|
-
note = "已补齐必填字段"
|
|
1023
|
-
if fixes:
|
|
1024
|
-
note += ";" + ";".join(str(x) for x in fixes)
|
|
1025
|
-
payload["source"] = (str(entry.get("source") or "") + ";" + note).lstrip(";")
|
|
1026
|
-
store.set_book_meta(task["id"], platform, payload)
|
|
1027
|
-
try:
|
|
1028
|
-
fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
|
|
1029
|
-
if fp.parent.is_dir():
|
|
1030
|
-
fp.write_text(render_markdown(task, platform, fixed), encoding="utf-8")
|
|
1031
|
-
except OSError:
|
|
1032
|
-
pass
|
|
1033
|
-
repaired += 1
|
|
1034
|
-
except Exception:
|
|
1035
|
-
continue
|
|
1036
|
-
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
|
+
# 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
|
|
35
|
+
# 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
|
|
36
|
+
# +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
|
|
37
|
+
# 七猫官方表单:一级→二级级联按频道;作品标签四组每组必选 1-3 个。
|
|
38
|
+
FIELD_LABELS = {
|
|
39
|
+
"fanqie": [
|
|
40
|
+
("book_name", "作品名"), ("signing_mode", "签约模式"),
|
|
41
|
+
("target_reader", "目标读者"), ("category", "主分类"),
|
|
42
|
+
("tags_theme", "主题标签"), ("tags_role", "角色标签"),
|
|
43
|
+
("tags_plot", "情节标签"),
|
|
44
|
+
("content_plot", "内容·情节"), ("content_emotion", "内容·情感"),
|
|
45
|
+
("content_character", "内容·人设"), ("content_world", "内容·世界观"),
|
|
46
|
+
("protagonist_1", "主角名1"),
|
|
47
|
+
("protagonist_2", "主角名2"), ("summary", "作品简介"),
|
|
48
|
+
],
|
|
49
|
+
"qimao": [
|
|
50
|
+
("book_name", "作品名称"), ("target_reader", "目标读者"),
|
|
51
|
+
("category_main", "一级分类"), ("category_sub", "二级分类"),
|
|
52
|
+
("tags_style", "风格标签"), ("tags_role", "角色标签"),
|
|
53
|
+
("tags_plot", "情节标签"), ("tags_bg", "背景标签"),
|
|
54
|
+
("protagonist_1", "主角名1"),
|
|
55
|
+
("protagonist_2", "主角名2"), ("status", "作品状态"),
|
|
56
|
+
("summary", "作品简介"),
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
PROMPT_HEAD = """你是网文编辑,熟悉 __PLATFORM__ 建书表单的填法。
|
|
61
|
+
|
|
62
|
+
__SKILLS__
|
|
63
|
+
请依据下方的小说素材(目标/故事圣经/大纲/已有章节开头),为这本书生成一套
|
|
64
|
+
「作品信息」,直接可用于在 __PLATFORM__ 创建作品。只输出一个 ```json 代码块,
|
|
65
|
+
不要输出其他内容。JSON 结构:
|
|
66
|
+
__SCHEMA__
|
|
67
|
+
硬性要求:
|
|
68
|
+
- 字段值必须贴合素材内容,不编造素材里没有的设定;简介不超过 500 字,突出卖点与钩子;
|
|
69
|
+
- 每个标签数组字段都必须至少给 1 个、并尽量贴着上限给满,只从对应组的选项表里选;
|
|
70
|
+
任何一组留空数组、漏掉字段或自造表外词都算错误;
|
|
71
|
+
- __OPTIONS_RULE__
|
|
72
|
+
- 题材健康,无违规内容,符合平台签约调性。
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# 签约模式真实选项(番茄建书表单 2026-09 实抓):连载模式 / 完本模式
|
|
76
|
+
FANQIE_MODES = ("连载模式", "完本模式")
|
|
77
|
+
|
|
78
|
+
FANQIE_SCHEMA = ('{"book_name": "作品名(15字内)", "signing_mode": "连载模式 或 完本模式", '
|
|
79
|
+
'"target_reader": "男频 或 女频", "category": "主分类(必选且只能选一个,从下方分类表选)", '
|
|
80
|
+
'"tags_theme": ["主题标签", …最多2个], "tags_role": ["角色标签", …最多2个], '
|
|
81
|
+
'"tags_plot": ["情节标签", …最多2个], '
|
|
82
|
+
'"content_plot": ["内容标签·情节", …最多4个], '
|
|
83
|
+
'"content_emotion": ["内容标签·情感", …最多2个], '
|
|
84
|
+
'"content_character": ["内容标签·人设", …最多4个], '
|
|
85
|
+
'"content_world": ["内容标签·世界观", …最多1个], '
|
|
86
|
+
'"protagonist_1": "主角名1(5字内)", '
|
|
87
|
+
'"protagonist_2": "主角名2(5字内,没有就空串)", "summary": "作品简介(500字内)"}')
|
|
88
|
+
|
|
89
|
+
QIMAO_SCHEMA = ('{"book_name": "作品名称(18字内)", "target_reader": "男生 或 女生", '
|
|
90
|
+
'"category_main": "一级分类(从该频道一级分类表选一个)", '
|
|
91
|
+
'"category_sub": "二级分类(必须是该一级下的二级)", '
|
|
92
|
+
'"tags_style": ["风格标签", 必选1-3个], '
|
|
93
|
+
'"tags_role": ["角色标签", 必选1-3个], '
|
|
94
|
+
'"tags_plot": ["情节标签", 必选1-3个], '
|
|
95
|
+
'"tags_bg": ["背景标签", 必选1-3个], '
|
|
96
|
+
'"protagonist_1": "主角名1(5字内)", '
|
|
97
|
+
'"protagonist_2": "主角名2(5字内,没有就空串)", "status": "连载中 或 已完结", '
|
|
98
|
+
'"summary": "作品简介(200字内)"}')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fq_tables(reader):
|
|
102
|
+
"""番茄按目标读者取对应选项表(男频/女频两套完全独立的分类与标签)。"""
|
|
103
|
+
from . import bookmeta_catalog as cat
|
|
104
|
+
if reader == "女频":
|
|
105
|
+
return (cat.FANQIE_FEMALE_CATEGORIES, cat.FANQIE_FEMALE_TAGS_THEME,
|
|
106
|
+
cat.FANQIE_FEMALE_TAGS_ROLE, cat.FANQIE_FEMALE_TAGS_PLOT)
|
|
107
|
+
return (cat.FANQIE_MALE_CATEGORIES, cat.FANQIE_MALE_TAGS_THEME,
|
|
108
|
+
cat.FANQIE_MALE_TAGS_ROLE, cat.FANQIE_MALE_TAGS_PLOT)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _fq_content_tables():
|
|
112
|
+
"""番茄内容标签四组(男女性共用):情节/情感/人设/世界观。"""
|
|
113
|
+
from . import bookmeta_catalog as cat
|
|
114
|
+
return (cat.FANQIE_CONTENT_PLOT, cat.FANQIE_CONTENT_EMOTION,
|
|
115
|
+
cat.FANQIE_CONTENT_CHARACTER, cat.FANQIE_CONTENT_WORLD)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fq_options_block(goal):
|
|
119
|
+
"""番茄提示词的真实选项注入:按 goal 推断读者给出对应分类与标签表。
|
|
120
|
+
|
|
121
|
+
2026-09-24 起首行加频道判定规则:素材明显属于另一频时 target_reader
|
|
122
|
+
如实填、分类留空(规整层按该频表校验并兜底补齐),防「注入男频表+
|
|
123
|
+
女频素材」时硬凑男频分类。"""
|
|
124
|
+
reader = "女频" if "女" in (goal or "") else "男频"
|
|
125
|
+
cats, theme, role, plot = _fq_tables(reader)
|
|
126
|
+
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
127
|
+
return ("先按素材判定目标读者(战争/军功/历史争霸/都市异能/系统→男频;"
|
|
128
|
+
"总裁豪门/宅斗宫斗/年代/婚恋情感线为主→女频),target_reader 如实填;"
|
|
129
|
+
"主分类从下方按目标读者 %s 预注入的表里选,素材明显属于另一频时"
|
|
130
|
+
"分类留空待系统按该频表补齐。主分类要贴世界观而非字面词——架空"
|
|
131
|
+
"王朝/军事/军功题材选历史古代或历史脑洞,西方奇幻仅限西式魔幻"
|
|
132
|
+
"世界观。\n"
|
|
133
|
+
"目标读者(%s)可用的主分类表(必选且只能选一个):%s\n"
|
|
134
|
+
"阅读标签三组各最多选 2 个——主题组:%s\n角色组:%s\n情节组(部分,选最贴合的):%s\n"
|
|
135
|
+
"内容标签四组——情节组(最多4个,部分):%s\n情感组(最多2个):%s\n"
|
|
136
|
+
"人设组(最多4个,部分):%s\n世界观组(最多1个):%s"
|
|
137
|
+
% (reader, reader, "、".join(cats), "、".join(theme), "、".join(role),
|
|
138
|
+
"、".join(plot[:50]), "、".join(c_plot[:60]), "、".join(c_emo),
|
|
139
|
+
"、".join(c_char[:60]), "、".join(c_world)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _qm_options_block(goal):
|
|
143
|
+
"""七猫提示词的真实选项注入:双频道级联 + 频道判定规则 + 跨频道禁令。
|
|
144
|
+
|
|
145
|
+
2026-09-24 前只按 goal 猜频道注入单频道表,但挡不住模型自带另一频道
|
|
146
|
+
的分类知识跨频道自造组合(男频军事文被配成 女生/古代言情/权谋天下,
|
|
147
|
+
级联还自洽)——必须双频道全量注入并明示判定规则。"""
|
|
148
|
+
from . import bookmeta_catalog as cat
|
|
149
|
+
cascades = ["%s频道(一级→二级):%s" % (ch, ";".join(
|
|
150
|
+
"%s(二级:%s)" % (m, "、".join(v)) for m, v in mains.items()))
|
|
151
|
+
for ch, mains in cat.QIMAO_CATS.items()]
|
|
152
|
+
pool = ["%s组(必选1-3个):%s" % (g, "、".join(t))
|
|
153
|
+
for g, t in cat.QIMAO_TAG_GROUPS.items()]
|
|
154
|
+
return ("先按素材判定目标读者频道:战争/军功/练兵/历史争霸/都市异能/"
|
|
155
|
+
"系统流/玄幻修仙→男生;总裁豪门/宅斗宫斗/年代/婚恋情感线为主→"
|
|
156
|
+
"女生。target_reader 必须填判定的频道,一级/二级分类只能从该频道"
|
|
157
|
+
"的级联表里选,严禁跨频道——如「权谋天下」只存在于女生·古代言情"
|
|
158
|
+
"下,男频军事历史文不得选用;「架空历史」属男生·历史。\n"
|
|
159
|
+
"%s\n作品标签四组(男女生同池):%s"
|
|
160
|
+
% ("。".join(cascades), ";".join(pool)))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---- 频道题材信号(2026-09-24 实案:男频军事文《戍边骑奴》被生成端配成
|
|
164
|
+
# 女生/古代言情/权谋天下——「权谋天下」字面贴合朝堂线但只存在于女生频道,
|
|
165
|
+
# 模型跨频道自造组合且级联自洽,目录校验拦不住,只能靠题材信号判频道。
|
|
166
|
+
# 必须保守:信号单侧命中才判,双侧命中/零命中一律不判(宁可不纠不可错纠)。
|
|
167
|
+
# 证据语料只用 goal+简介:分类字段是嫌疑输出、标签池男女同池,都不算证据。
|
|
168
|
+
_CH_SIGNAL_MALE = ("军功", "戍边", "骑奴", "特种兵", "练兵", "杀伐", "阵斩",
|
|
169
|
+
"百夫长", "大军", "蛮族", "边军", "边关", "边疆", "战场",
|
|
170
|
+
"争霸", "兵王", "战神", "赘婿", "武侠", "修仙", "修真",
|
|
171
|
+
"玄幻", "机甲", "星际", "电竞", "网游", "领主")
|
|
172
|
+
_CH_SIGNAL_FEMALE = ("言情", "总裁", "豪门", "宅斗", "宫斗", "嫡女", "庶女",
|
|
173
|
+
"甜宠", "虐恋", "宠妻", "宠夫", "王妃", "医妃", "皇后",
|
|
174
|
+
"公主", "丫鬟", "千金", "女配", "追妻", "闪婚", "隐婚",
|
|
175
|
+
"婆媳", "闺蜜", "女尊", "女强", "军婚", "宫闱")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _channel_anchor(text):
|
|
179
|
+
"""题材信号判频道:男/女信号词单侧命中返回 男生/女生,否则 None。"""
|
|
180
|
+
t = str(text or "")
|
|
181
|
+
if not t:
|
|
182
|
+
return None
|
|
183
|
+
male = any(w in t for w in _CH_SIGNAL_MALE)
|
|
184
|
+
female = any(w in t for w in _CH_SIGNAL_FEMALE)
|
|
185
|
+
if male and not female:
|
|
186
|
+
return "男生"
|
|
187
|
+
if female and not male:
|
|
188
|
+
return "女生"
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_READER_OF = {"fanqie": {"男生": "男频", "女生": "女频"},
|
|
193
|
+
"qimao": {"男频": "男生", "女频": "女生"}}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _reader_anchor(task, platform, meta):
|
|
197
|
+
"""频道锚:题材信号(goal+简介)优先,其次同书另一平台已定的读者。
|
|
198
|
+
|
|
199
|
+
返回 (该平台的读者词, 依据说明) 或 (None, "")。"""
|
|
200
|
+
corpus = "。".join(x for x in ((task or {}).get("goal") or "",
|
|
201
|
+
(meta or {}).get("summary") or "") if x)
|
|
202
|
+
sig = _channel_anchor(corpus)
|
|
203
|
+
if sig:
|
|
204
|
+
# 信号词是男生/女生:七猫原样用,番茄翻成 男频/女频
|
|
205
|
+
own = sig if platform == "qimao" else {"男生": "男频", "女生": "女频"}[sig]
|
|
206
|
+
return own, "题材信号"
|
|
207
|
+
peer_plat = "qimao" if platform == "fanqie" else "fanqie"
|
|
208
|
+
peer = (((task or {}).get("book_meta") or {}).get(peer_plat) or {})
|
|
209
|
+
if peer.get("status") == "done":
|
|
210
|
+
mapped = _READER_OF[platform].get(
|
|
211
|
+
(peer.get("data") or {}).get("target_reader"))
|
|
212
|
+
if mapped:
|
|
213
|
+
return mapped, "同书另一平台(%s)已定读者" % PLATFORMS[peer_plat]["label"]
|
|
214
|
+
return None, ""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _apply_fix_note(meta, source):
|
|
218
|
+
"""把 _fill_required_fields 记录的纠偏说明并进 source。
|
|
219
|
+
|
|
220
|
+
meta 里的 _fixes 是临时键,随出随清,不落 book_meta 数据(否则
|
|
221
|
+
repair_existing 的幂等比较会每次不等、启动反复重写)。"""
|
|
222
|
+
fixes = meta.pop("_fixes", None)
|
|
223
|
+
if fixes:
|
|
224
|
+
source = (str(source or "") + ";" + ";".join(str(x) for x in fixes)
|
|
225
|
+
).strip(";")
|
|
226
|
+
return source
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _collect_material(task):
|
|
230
|
+
"""开书资料素材:目标 + 附件上下文 + 故事圣经 + 最近一次大纲 + 第一章开头。"""
|
|
231
|
+
from . import store # 惰性导入,同 planner:避免测试环境导入顺序问题
|
|
232
|
+
parts = ["## 小说目标\n" + (task.get("goal") or "(无)")]
|
|
233
|
+
ctx = (task.get("context") or "").strip()
|
|
234
|
+
if ctx:
|
|
235
|
+
parts.append("## 背景与上下文\n" + ctx[:2000])
|
|
236
|
+
_, bible, _ = store.read_story_bible(task["id"])
|
|
237
|
+
if bible:
|
|
238
|
+
parts.append("## 故事圣经\n" + bible[:6000])
|
|
239
|
+
outline = None
|
|
240
|
+
for r in store.task_runs(task["id"]):
|
|
241
|
+
o = r.get("outline")
|
|
242
|
+
if o and o.get("chapters") and not o.get("degraded"):
|
|
243
|
+
outline = o
|
|
244
|
+
break
|
|
245
|
+
if outline:
|
|
246
|
+
lines = ["书名:%s" % (outline.get("book_title") or "(未定)")]
|
|
247
|
+
for k, c in enumerate(outline["chapters"]):
|
|
248
|
+
lines.append("第 %d 章《%s》:%s" % (k + 1, c.get("title", ""),
|
|
249
|
+
str(c.get("beats") or "")[:120]))
|
|
250
|
+
parts.append("## 已有大纲\n" + "\n".join(lines))
|
|
251
|
+
head = ""
|
|
252
|
+
try:
|
|
253
|
+
from . import runner
|
|
254
|
+
# 章节文件命名并存:连载流水线落 chapter-01.md(两位),有的书是
|
|
255
|
+
# chapter-001.md(三位)——只认一种会瞎掉「第一章开头」素材
|
|
256
|
+
# (2026-09-28 马甲书案:明明 8 章成稿在手却报「没有正文」)。
|
|
257
|
+
for name in ("chapter-001.md", "chapter-01.md", "chapter-1.md"):
|
|
258
|
+
p = Path(task.get("workdir") or "") / name
|
|
259
|
+
if p.is_file():
|
|
260
|
+
head = runner.read_text_any_enc(p)[:_FIRST_CH_HEAD]
|
|
261
|
+
break
|
|
262
|
+
except OSError:
|
|
263
|
+
pass
|
|
264
|
+
if head:
|
|
265
|
+
parts.append("## 第一章开头\n" + head)
|
|
266
|
+
return "\n\n".join(parts), outline
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _tags(v, limit=6):
|
|
270
|
+
"""(旧字段兜底保留)通用标签列表清洗:去重/去空/截断。"""
|
|
271
|
+
if not isinstance(v, list):
|
|
272
|
+
return []
|
|
273
|
+
out = []
|
|
274
|
+
for x in v:
|
|
275
|
+
s = str(x).strip()[:12]
|
|
276
|
+
if s and s not in out:
|
|
277
|
+
out.append(s)
|
|
278
|
+
if len(out) >= limit:
|
|
279
|
+
break
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _name(v, limit):
|
|
284
|
+
return str(v or "").strip().replace("\n", " ")[:limit]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _summary(v):
|
|
288
|
+
"""剥 markdown 装饰后截断。2026-09-23 实案:简介混成调研报告标题
|
|
289
|
+
「# 网文选题分析报告…」——模型把研究素材当小说素材时输出的是标题行,
|
|
290
|
+
至少不能把 #/* 符号和报告腔标题原样带进建书表单。"""
|
|
291
|
+
t = re.sub(r"^\s*#{1,6}\s*", "", str(v or "").strip())
|
|
292
|
+
t = re.sub(r"(?m)^\s*[-*•]\s*", "", t)
|
|
293
|
+
t = t.replace("**", "").replace("__", "")
|
|
294
|
+
t = re.sub(r"\n{2,}", "\n", t).strip()
|
|
295
|
+
return t[:500]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _in_table(v, table, limit=10):
|
|
299
|
+
"""值必须在平台真实选项表内;不在返回空串(宁缺勿错——填了假选项用户还得手动改)。"""
|
|
300
|
+
s = _name(v, limit)
|
|
301
|
+
return s if s in (table or []) else ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _filter_table(v, table, limit=6):
|
|
305
|
+
"""多选标签:只保留表内项(保持模型给的顺序),去重截断。"""
|
|
306
|
+
if not isinstance(v, list):
|
|
307
|
+
return []
|
|
308
|
+
out = []
|
|
309
|
+
for x in v:
|
|
310
|
+
s = str(x).strip()[:12]
|
|
311
|
+
if s and s in (table or []) and s not in out:
|
|
312
|
+
out.append(s)
|
|
313
|
+
if len(out) >= limit:
|
|
314
|
+
break
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _norm(data, platform, goal=""):
|
|
319
|
+
"""规范化平台字段输出;不合规返回 None。
|
|
320
|
+
|
|
321
|
+
分类/标签按平台真实选项表校验(bookmeta_catalog):分类单选不在表内清空、
|
|
322
|
+
标签多选只留表内项;七猫二级按级联校验(一级无级联记录时宽松放行)。"""
|
|
323
|
+
if not isinstance(data, dict):
|
|
324
|
+
return None
|
|
325
|
+
book_name = _name(data.get("book_name"), 15 if platform == "fanqie" else 15)
|
|
326
|
+
summary = _summary(data.get("summary"))
|
|
327
|
+
if not book_name and not summary:
|
|
328
|
+
return None
|
|
329
|
+
if platform == "fanqie":
|
|
330
|
+
reader = str(data.get("target_reader") or "").strip()
|
|
331
|
+
if reader not in ("男频", "女频"):
|
|
332
|
+
reader = "女频" if "女" in (goal or "") else "男频"
|
|
333
|
+
mode = str(data.get("signing_mode") or "").strip()
|
|
334
|
+
if mode not in FANQIE_MODES:
|
|
335
|
+
mode = "连载模式"
|
|
336
|
+
cats, theme, role, plot = _fq_tables(reader)
|
|
337
|
+
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
338
|
+
# 兼容旧字段:read_tags/content_tags 并入情节/主题组(老数据不丢)
|
|
339
|
+
legacy_read = data.get("read_tags") if isinstance(data.get("read_tags"), list) else []
|
|
340
|
+
return {"book_name": book_name, "signing_mode": mode, "target_reader": reader,
|
|
341
|
+
"category": _in_table(data.get("category"), cats),
|
|
342
|
+
"tags_theme": _filter_table(data.get("tags_theme"), theme, 2),
|
|
343
|
+
"tags_role": _filter_table(data.get("tags_role"), role, 2),
|
|
344
|
+
"tags_plot": _filter_table(data.get("tags_plot"), plot, 2),
|
|
345
|
+
"content_plot": _filter_table(data.get("content_plot"), c_plot, 4),
|
|
346
|
+
"content_emotion": _filter_table(data.get("content_emotion"), c_emo, 2),
|
|
347
|
+
"content_character": _filter_table(data.get("content_character"), c_char, 4),
|
|
348
|
+
"content_world": _filter_table(data.get("content_world"), c_world, 1),
|
|
349
|
+
"legacy_read_tags": _filter_table(legacy_read, plot + theme + role),
|
|
350
|
+
"protagonist_1": _name(data.get("protagonist_1"), 5),
|
|
351
|
+
"protagonist_2": _name(data.get("protagonist_2"), 5),
|
|
352
|
+
"summary": summary}
|
|
353
|
+
from . import bookmeta_catalog as cat
|
|
354
|
+
reader = str(data.get("target_reader") or "").strip()
|
|
355
|
+
if reader not in ("男生", "女生"):
|
|
356
|
+
reader = "女生" if "女" in (goal or "") else "男生"
|
|
357
|
+
status = str(data.get("status") or "").strip()
|
|
358
|
+
if status not in ("连载中", "已完结"):
|
|
359
|
+
status = "连载中"
|
|
360
|
+
channel = cat.QIMAO_CATS.get(reader) or {}
|
|
361
|
+
c_main = _in_table(data.get("category_main"), list(channel))
|
|
362
|
+
c_sub = _name(data.get("category_sub"), 10)
|
|
363
|
+
if c_sub and not (c_main and c_sub in (channel.get(c_main) or [])):
|
|
364
|
+
# 一级/二级不自洽(一级空、或二级不属于该频道——如女生专属的
|
|
365
|
+
# 「现实故事」配了男生频道):跨频道反查官方目录纠正,频道与
|
|
366
|
+
# 一级跟着二级走(表单级联的选项集以此为准);全目录都没有
|
|
367
|
+
# 才清空——表单选不出的词不硬填
|
|
368
|
+
loc = cat.qimao_locate_sub(c_sub)
|
|
369
|
+
if loc:
|
|
370
|
+
reader, c_main = loc
|
|
371
|
+
else:
|
|
372
|
+
c_sub = ""
|
|
373
|
+
# 作品标签四组(官方每组必选 1-3):逐组过滤 + 截到 3;老数据 tags 并入
|
|
374
|
+
legacy_tags = data.get("tags") if isinstance(data.get("tags"), list) else []
|
|
375
|
+
groups = {}
|
|
376
|
+
for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
|
|
377
|
+
("tags_plot", "情节"), ("tags_bg", "背景")):
|
|
378
|
+
got = _filter_table(data.get(key), cat.QIMAO_TAG_GROUPS[gname], 3)
|
|
379
|
+
if len(got) < 3 and legacy_tags:
|
|
380
|
+
for t in _filter_table(legacy_tags, cat.QIMAO_TAG_GROUPS[gname], 3):
|
|
381
|
+
if t not in got:
|
|
382
|
+
got.append(t)
|
|
383
|
+
if len(got) >= 3:
|
|
384
|
+
break
|
|
385
|
+
groups[key] = got
|
|
386
|
+
return {"book_name": book_name, "target_reader": reader,
|
|
387
|
+
"category_main": c_main, "category_sub": c_sub,
|
|
388
|
+
"tags_style": groups["tags_style"], "tags_role": groups["tags_role"],
|
|
389
|
+
"tags_plot": groups["tags_plot"], "tags_bg": groups["tags_bg"],
|
|
390
|
+
"protagonist_1": _name(data.get("protagonist_1"), 5),
|
|
391
|
+
"protagonist_2": _name(data.get("protagonist_2"), 5),
|
|
392
|
+
"status": status, "summary": summary}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _template_meta(task, platform, outline):
|
|
396
|
+
"""兜底模板:只做素材搬运归纳(书名/简介来自大纲与目标),其余留空待补。
|
|
397
|
+
|
|
398
|
+
标签/分类尽力而为:扫描素材文本(goal/圣经/大纲书名)里恰好命中平台
|
|
399
|
+
选项表的词就带上——模板没有模型理解力,不做语义归纳,命中多少算多少,
|
|
400
|
+
其余留「待补充」让用户手选(宁缺勿错,不填表外词)。"""
|
|
401
|
+
goal = task.get("goal") or ""
|
|
402
|
+
book_name = str((outline or {}).get("book_title") or task.get("title") or "").strip()
|
|
403
|
+
summary = goal.splitlines()[0][:500] if goal.strip() else ""
|
|
404
|
+
if not summary:
|
|
405
|
+
summary = book_name or "(待补充)" # 全空素材也要有返回,_norm 才不会拒绝
|
|
406
|
+
material = _material_text(task, outline)
|
|
407
|
+
data = {"book_name": book_name, "summary": summary}
|
|
408
|
+
if platform == "fanqie":
|
|
409
|
+
reader = "女频" if "女" in goal else "男频"
|
|
410
|
+
cats, theme, role, plot = _fq_tables(reader)
|
|
411
|
+
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
412
|
+
data.update({"target_reader": reader,
|
|
413
|
+
"category": _scan_table(material, cats, 1),
|
|
414
|
+
"tags_theme": _scan_table(material, theme, 2),
|
|
415
|
+
"tags_role": _scan_table(material, role, 2),
|
|
416
|
+
"tags_plot": _scan_table(material, plot, 2),
|
|
417
|
+
"content_plot": _scan_table(material, c_plot, 4),
|
|
418
|
+
"content_emotion": _scan_table(material, c_emo, 2),
|
|
419
|
+
"content_character": _scan_table(material, c_char, 4),
|
|
420
|
+
"content_world": _scan_table(material, c_world, 1)})
|
|
421
|
+
else:
|
|
422
|
+
from . import bookmeta_catalog as cat
|
|
423
|
+
reader = "女生" if "女" in goal else "男生"
|
|
424
|
+
data["target_reader"] = reader
|
|
425
|
+
for main, subs in (cat.QIMAO_CATS.get(reader) or {}).items():
|
|
426
|
+
if main in material:
|
|
427
|
+
data["category_main"] = main
|
|
428
|
+
for s in subs: # 二级名撞素材即选(如「宫闱宅斗」)
|
|
429
|
+
if s in material:
|
|
430
|
+
data["category_sub"] = s
|
|
431
|
+
break
|
|
432
|
+
break
|
|
433
|
+
for key, gname in (("tags_style", "风格"), ("tags_role", "角色"),
|
|
434
|
+
("tags_plot", "情节"), ("tags_bg", "背景")):
|
|
435
|
+
data[key] = _scan_table(material, cat.QIMAO_TAG_GROUPS[gname], 3)
|
|
436
|
+
return _norm(data, platform, goal)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _scan_table(text, table, limit):
|
|
440
|
+
"""素材文本里命中的选项表词(按表序取,保证真实存在)。"""
|
|
441
|
+
if not text:
|
|
442
|
+
return []
|
|
443
|
+
out = []
|
|
444
|
+
for word in table:
|
|
445
|
+
if len(word) >= 2 and word in text:
|
|
446
|
+
out.append(word)
|
|
447
|
+
if len(out) >= limit:
|
|
448
|
+
break
|
|
449
|
+
return out
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _material_text(task, outline):
|
|
453
|
+
"""标签词面扫描用的素材文本:goal + 圣经 + 大纲书名(模板兜底同源)。"""
|
|
454
|
+
material = task.get("goal") or ""
|
|
455
|
+
try:
|
|
456
|
+
_, bible, _ = store_read_bible(task)
|
|
457
|
+
material += "\n" + (bible or "")
|
|
458
|
+
except Exception:
|
|
459
|
+
pass
|
|
460
|
+
if outline:
|
|
461
|
+
material += "\n" + str(outline.get("book_title") or "")
|
|
462
|
+
return material
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _fill_tag_gaps(task, platform, meta, outline):
|
|
466
|
+
"""llm 归一化后仍为空的标签组:用素材词面扫描补一轮(命中多少算多少)。
|
|
467
|
+
|
|
468
|
+
模型偶尔整组漏给(实测 codex-cli 只填了内容标签前两组);空着用户就得
|
|
469
|
+
手选,扫描命中的是官方表内词,补上好过留白——已有的组不动,不覆盖模型。"""
|
|
470
|
+
material = _material_text(task, outline)
|
|
471
|
+
if not material:
|
|
472
|
+
return meta
|
|
473
|
+
goal = task.get("goal") or ""
|
|
474
|
+
if platform == "fanqie":
|
|
475
|
+
reader = meta.get("target_reader") or ("女频" if "女" in goal else "男频")
|
|
476
|
+
cats, theme, role, plot = _fq_tables(reader)
|
|
477
|
+
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
478
|
+
pairs = [("tags_theme", theme, 2), ("tags_role", role, 2), ("tags_plot", plot, 2),
|
|
479
|
+
("content_plot", c_plot, 4), ("content_emotion", c_emo, 2),
|
|
480
|
+
("content_character", c_char, 4), ("content_world", c_world, 1)]
|
|
481
|
+
else:
|
|
482
|
+
from . import bookmeta_catalog as cat
|
|
483
|
+
pairs = [(k, cat.QIMAO_TAG_GROUPS[g], 3) for k, g in
|
|
484
|
+
(("tags_style", "风格"), ("tags_role", "角色"),
|
|
485
|
+
("tags_plot", "情节"), ("tags_bg", "背景"))]
|
|
486
|
+
for key, table, limit in pairs:
|
|
487
|
+
if not meta.get(key):
|
|
488
|
+
meta[key] = _scan_table(material, table, limit)
|
|
489
|
+
return meta
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _material_names(material):
|
|
493
|
+
"""从圣经/大纲/章节里提取明确标注的人物名。
|
|
494
|
+
|
|
495
|
+
只接受带人物语义的短语(如“女主:阿禾”或“男主叫陆沉”),避免把
|
|
496
|
+
普通句子里的名词误当成主角。返回顺序去重后的最多两个名字。
|
|
497
|
+
"""
|
|
498
|
+
text = str(material or "")
|
|
499
|
+
out = []
|
|
500
|
+
patterns = (
|
|
501
|
+
r"(?:主角|主人公|男主(?:角)?|女主(?:角)?|男一|女一|姓名|名字)"
|
|
502
|
+
r"\s*(?:是|叫|为|:|:)\s*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z·]{1,7})",
|
|
503
|
+
r"人物\s*[::]\s*([^\n。;;]{2,40})",
|
|
504
|
+
)
|
|
505
|
+
for pat in patterns:
|
|
506
|
+
for m in re.finditer(pat, text):
|
|
507
|
+
raw = m.group(1)
|
|
508
|
+
chunks = re.split(r"[、,,/和及与]|\s+", raw)
|
|
509
|
+
for chunk in chunks:
|
|
510
|
+
name = re.sub(r"[^\u4e00-\u9fffA-Za-z·]", "", chunk)
|
|
511
|
+
if 2 <= len(name) <= 8 and name not in out:
|
|
512
|
+
out.append(name[:5])
|
|
513
|
+
if len(out) >= 2:
|
|
514
|
+
return out
|
|
515
|
+
return out
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _pick_option(material, table, keywords=()):
|
|
519
|
+
"""从官方选项表中按素材命中词选择一个,完全命中不到时用首项。"""
|
|
520
|
+
text = str(material or "")
|
|
521
|
+
for item in table:
|
|
522
|
+
if len(item) >= 2 and item in text:
|
|
523
|
+
return item
|
|
524
|
+
for key, item in keywords:
|
|
525
|
+
if key and key in text and item in table:
|
|
526
|
+
return item
|
|
527
|
+
return table[0] if table else ""
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _has_value(value):
|
|
531
|
+
"""判断建书字段是否真正有值(兼容列表和历史占位文本)。"""
|
|
532
|
+
placeholders = {"待补充", "(待补充)", "(待补充)"}
|
|
533
|
+
if isinstance(value, list):
|
|
534
|
+
return any((text := str(x or "").strip()) and text not in placeholders
|
|
535
|
+
for x in value)
|
|
536
|
+
text = str(value or "").strip()
|
|
537
|
+
return bool(text) and text not in placeholders
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _md_plain(text):
|
|
541
|
+
"""markdown 轻洗(在 _summary 净化之上):每行剥标题/列表/引用符与行内
|
|
542
|
+
强调符,合并成一段正文文字(建书表单简介是单行框)。"""
|
|
543
|
+
out = []
|
|
544
|
+
for ln in _summary(text).splitlines():
|
|
545
|
+
ln = re.sub(r"^\s*(#{1,6}\s*|[-*+]\s+|>\s*|\d+[.、))]\s*)", "", ln)
|
|
546
|
+
ln = re.sub(r"[*_`#>]+", "", ln).strip()
|
|
547
|
+
if ln:
|
|
548
|
+
out.append(ln)
|
|
549
|
+
return "".join(out)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _summary_ok(value):
|
|
553
|
+
"""简介够不够格进建书表单:番茄表单硬性要求 50-500 字(占位文案即
|
|
554
|
+
「请输入50-500字」),markdown 标题/占位符过不了平台提交校验,且自动
|
|
555
|
+
化流程看不到平台的字段报错,只会误报成「未登录或改版」。"""
|
|
556
|
+
return 50 <= len(_md_plain(value)) <= 500
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _prose_summary(cur, material):
|
|
560
|
+
"""简介兜底清理:markdown 洗成正文;洗完仍不足 50 字就从素材正文取。
|
|
561
|
+
取不出够长的正文时原样返回——由建书前置闸拦停并提示补写,不硬编。"""
|
|
562
|
+
text = _md_plain(cur)
|
|
563
|
+
if len(text) >= 50:
|
|
564
|
+
return text[:500]
|
|
565
|
+
body = _md_plain(material)
|
|
566
|
+
if len(body) >= 50:
|
|
567
|
+
return body[:500]
|
|
568
|
+
return str(cur or "").strip()
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _fill_required_fields(task, platform, meta, outline, material):
|
|
572
|
+
"""生成阶段的必填字段闸门。
|
|
573
|
+
|
|
574
|
+
``_norm`` 仍保持“表外值清空”的纯校验语义,供旧数据修复和单测使用;
|
|
575
|
+
这里仅在真正交付作品信息前补齐平台建书表单的必填项,避免 UI 展示一整
|
|
576
|
+
屏“待补充”而无法直接复制建书。所有分类/标签仍来自官方目录。
|
|
577
|
+
"""
|
|
578
|
+
from . import bookmeta_catalog as cat
|
|
579
|
+
|
|
580
|
+
material = str(material or "")
|
|
581
|
+
names = _material_names(material)
|
|
582
|
+
female_words = ("女频", "女生", "女主", "言情", "古言", "恋爱")
|
|
583
|
+
female = any(x in ((task.get("goal") or "") + material) for x in female_words)
|
|
584
|
+
defaults = ("沈知微", "顾砚川", "林知夏") if female else ("陆沉", "苏晚", "程野")
|
|
585
|
+
if not _has_value(meta.get("protagonist_1")):
|
|
586
|
+
meta["protagonist_1"] = names[0] if names else defaults[0]
|
|
587
|
+
if not _has_value(meta.get("protagonist_2")):
|
|
588
|
+
candidate = names[1] if len(names) > 1 else defaults[1]
|
|
589
|
+
if candidate == meta["protagonist_1"]:
|
|
590
|
+
candidate = next((name for name in defaults if name != meta["protagonist_1"]), defaults[2])
|
|
591
|
+
meta["protagonist_2"] = candidate
|
|
592
|
+
|
|
593
|
+
# 简介质量:平台建书表单对简介有硬字数校验(番茄 50-500 字),markdown
|
|
594
|
+
# 标题/占位文本过不了提交且流程看不到报错——洗成正文,不够长从素材取
|
|
595
|
+
meta["summary"] = _prose_summary(meta.get("summary"), material)
|
|
596
|
+
|
|
597
|
+
# 频道锚:题材信号/同书另一平台的读者与模型声称的频道冲突时以锚为准
|
|
598
|
+
# 翻转(2026-09-24 实案:男频军事文被配成 女生/古代言情/权谋天下,
|
|
599
|
+
# 级联自洽骗过目录校验)。翻转后旧频道分类作废,由下方素材补齐逻辑
|
|
600
|
+
# 按正确频道重选;纠偏说明记 _fixes 临时键,由调用方并进 source。
|
|
601
|
+
anchor, why = _reader_anchor(task, platform, meta)
|
|
602
|
+
if anchor and meta.get("target_reader") != anchor:
|
|
603
|
+
meta["_fixes"] = ["频道纠偏:%s→%s(依据:%s)"
|
|
604
|
+
% (meta.get("target_reader") or "未填", anchor, why)]
|
|
605
|
+
meta["target_reader"] = anchor
|
|
606
|
+
if platform == "qimao":
|
|
607
|
+
ch = cat.QIMAO_CATS.get(anchor) or {}
|
|
608
|
+
if meta.get("category_main") not in ch:
|
|
609
|
+
meta["category_main"] = ""
|
|
610
|
+
ch_main = meta.get("category_main")
|
|
611
|
+
if meta.get("category_sub") and not (
|
|
612
|
+
ch_main and meta["category_sub"] in (ch.get(ch_main) or [])):
|
|
613
|
+
meta["category_sub"] = ""
|
|
614
|
+
else:
|
|
615
|
+
fq_cats, _, _, _ = _fq_tables(anchor)
|
|
616
|
+
if meta.get("category") not in fq_cats:
|
|
617
|
+
meta["category"] = ""
|
|
618
|
+
|
|
619
|
+
if platform == "fanqie":
|
|
620
|
+
reader = meta.get("target_reader") or ("女频" if female else "男频")
|
|
621
|
+
if reader not in ("男频", "女频"):
|
|
622
|
+
reader = "女频" if female else "男频"
|
|
623
|
+
meta["target_reader"] = reader
|
|
624
|
+
cats, theme, role, plot = _fq_tables(reader)
|
|
625
|
+
c_plot, c_emo, c_char, c_world = _fq_content_tables()
|
|
626
|
+
if meta.get("category") not in cats:
|
|
627
|
+
# 关键词兜底按频道给(2026-09-24 实案《戍边骑奴》:旧表不分频道,
|
|
628
|
+
# 「戍边→古风世情」是女频词,男频素材永远选不中,全部落到男频表
|
|
629
|
+
# 首项「西方奇幻」——架空王朝军事文被配成西式魔幻)
|
|
630
|
+
kw = (("历史", "历史古代"), ("古代", "历史古代"),
|
|
631
|
+
("王朝", "历史脑洞"), ("架空", "历史脑洞"),
|
|
632
|
+
("戍边", "历史古代"), ("边关", "历史古代"),
|
|
633
|
+
("抗战", "抗战谍战"), ("谍战", "抗战谍战"),
|
|
634
|
+
("军", "历史古代"), ("种田", "都市种田"),
|
|
635
|
+
("都市", "都市脑洞"), ("悬疑", "悬疑脑洞")) if reader == "男频" else (
|
|
636
|
+
("宅斗", "宫斗宅斗"), ("宫斗", "宫斗宅斗"),
|
|
637
|
+
("总裁", "豪门总裁"), ("豪门", "豪门总裁"),
|
|
638
|
+
("古代", "古风世情"), ("古言", "古言脑洞"),
|
|
639
|
+
("年代", "年代"), ("种田", "种田"),
|
|
640
|
+
("婚姻", "职场婚恋"), ("恋爱", "职场婚恋"),
|
|
641
|
+
("悬疑", "女频悬疑"), ("快穿", "快穿"))
|
|
642
|
+
meta["category"] = _pick_option(material, cats, kw)
|
|
643
|
+
groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
|
|
644
|
+
("tags_plot", plot, 2), ("content_plot", c_plot, 4),
|
|
645
|
+
("content_emotion", c_emo, 2), ("content_character", c_char, 4),
|
|
646
|
+
("content_world", c_world, 1))
|
|
647
|
+
else:
|
|
648
|
+
reader = meta.get("target_reader") or ("女生" if female else "男生")
|
|
649
|
+
if reader not in ("男生", "女生"):
|
|
650
|
+
reader = "女生" if female else "男生"
|
|
651
|
+
meta["target_reader"] = reader
|
|
652
|
+
channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
|
|
653
|
+
main = meta.get("category_main")
|
|
654
|
+
if main not in channel:
|
|
655
|
+
# 关键词兜底按频道给(2026-09-24 频道锚落地后读方可信,旧的
|
|
656
|
+
# 「戍边→古代言情」在男生频道下永远选不中白占位)
|
|
657
|
+
kw = (("戍边", "历史"), ("边关", "历史"), ("军", "军事"),
|
|
658
|
+
("都市", "都市"), ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")
|
|
659
|
+
) if reader == "男生" else (
|
|
660
|
+
("宅斗", "古代言情"), ("宫斗", "古代言情"),
|
|
661
|
+
("古代", "古代言情"), ("总裁", "现代言情"),
|
|
662
|
+
("年代", "现代言情"), ("都市", "现代言情"))
|
|
663
|
+
main = _pick_option(material, list(channel), kw)
|
|
664
|
+
meta["category_main"] = main
|
|
665
|
+
subs = channel.get(main) or []
|
|
666
|
+
sub = meta.get("category_sub")
|
|
667
|
+
if sub not in subs:
|
|
668
|
+
located = cat.qimao_locate_sub(sub)
|
|
669
|
+
if located:
|
|
670
|
+
reader, main = located
|
|
671
|
+
meta["target_reader"] = reader
|
|
672
|
+
meta["category_main"] = main
|
|
673
|
+
subs = cat.QIMAO_CATS[reader][main]
|
|
674
|
+
else:
|
|
675
|
+
sub = ""
|
|
676
|
+
if sub not in subs:
|
|
677
|
+
meta["category_sub"] = _pick_option(material, subs)
|
|
678
|
+
groups = (("tags_style", cat.QIMAO_TAG_GROUPS["风格"], 3),
|
|
679
|
+
("tags_role", cat.QIMAO_TAG_GROUPS["角色"], 3),
|
|
680
|
+
("tags_plot", cat.QIMAO_TAG_GROUPS["情节"], 3),
|
|
681
|
+
("tags_bg", cat.QIMAO_TAG_GROUPS["背景"], 3))
|
|
682
|
+
for key, table, limit in groups:
|
|
683
|
+
values = meta.get(key)
|
|
684
|
+
if not isinstance(values, list):
|
|
685
|
+
values = []
|
|
686
|
+
values = [str(v).strip() for v in values if str(v or "").strip() in table]
|
|
687
|
+
if not values:
|
|
688
|
+
values = [_pick_option(material, table)]
|
|
689
|
+
meta[key] = values[:limit]
|
|
690
|
+
return meta
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def store_read_bible(task):
|
|
694
|
+
"""圣经读取的小包装(模板兜底用;异常向上抛由调用方吞掉)。"""
|
|
695
|
+
from . import store
|
|
696
|
+
fp, text, err = store.read_story_bible(task.get("id") or "")
|
|
697
|
+
if err:
|
|
698
|
+
raise ValueError(err)
|
|
699
|
+
return fp, text, None
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _resolve_author(task):
|
|
703
|
+
"""作者 CLI 兜底用:按「实现」角色选一个 real 智能体(同连载起草的路由口径)。"""
|
|
704
|
+
try:
|
|
705
|
+
from . import catalog, manager, registry, router
|
|
706
|
+
agents = registry.effective_agents(catalog.load(), manager.detect_all())
|
|
707
|
+
agent, _ = router.pick([a for a in agents if a.get("mode") == "real"],
|
|
708
|
+
"implement", task.get("type") or "serial_novel")
|
|
709
|
+
return agent
|
|
710
|
+
except Exception:
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def make_book_meta(task, platform, author_agent=None, log_path=None):
|
|
715
|
+
"""生成一个平台的作品信息。返回 (meta dict, source 字符串);模板兜底永远有返回。"""
|
|
716
|
+
from . import knowledge, modelhub, planner, runner, skills # 惰性导入,同 planner
|
|
717
|
+
material, outline = _collect_material(task)
|
|
718
|
+
# 素材充分性门:没有大纲也没有章稿/圣经的任务(如「帮我定方向」的选题
|
|
719
|
+
# 研究任务)没有可推介的小说要素——生成只会产出报告腔垃圾
|
|
720
|
+
# (2026-09-23 实案:简介=「# 网文选题分析报告…」)。宁明确拒绝。
|
|
721
|
+
if not outline and "## 第一章开头" not in material and "## 故事圣经" not in material:
|
|
722
|
+
raise ValueError(
|
|
723
|
+
"该任务还没有小说大纲、正文或故事圣经,无法生成作品信息——"
|
|
724
|
+
"先完成大纲/首批章节后再来一键生成")
|
|
725
|
+
schema = {"fanqie": FANQIE_SCHEMA, "qimao": QIMAO_SCHEMA}.get(platform) or ""
|
|
726
|
+
plat_label = PLATFORMS[platform]["label"]
|
|
727
|
+
sk_block, _ = skills.block_for(task)
|
|
728
|
+
# 实案教训(2026-09-23):素材偏研究向时模型把简介写成「分析报告标题」
|
|
729
|
+
summary_rule = ("- summary 必须是面向读者的小说推介文案:点出题材、主角、核心冲突与悬念,"
|
|
730
|
+
"200/500 字内成段;绝不允许写成报告标题、要点清单或任务说明。")
|
|
731
|
+
kb_block = knowledge.block_for(task)
|
|
732
|
+
if kb_block:
|
|
733
|
+
sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
|
|
734
|
+
goal = task.get("goal") or ""
|
|
735
|
+
# 真实选项注入:分类/标签表按平台与读者频段给全(模型只能从表里选)
|
|
736
|
+
options = (_fq_options_block(goal) if platform == "fanqie" else _qm_options_block(goal))
|
|
737
|
+
prompt = (PROMPT_HEAD.replace("__PLATFORM__", plat_label)
|
|
738
|
+
.replace("__SKILLS__", sk_block)
|
|
739
|
+
.replace("__SCHEMA__", schema)
|
|
740
|
+
.replace("__OPTIONS_RULE__", options + "\n" + summary_rule)
|
|
741
|
+
+ "\n\n## 小说素材\n" + material)
|
|
742
|
+
|
|
743
|
+
orch = None
|
|
744
|
+
if not template_only():
|
|
745
|
+
try:
|
|
746
|
+
orch = modelhub.resolve_orchestrator()
|
|
747
|
+
except Exception:
|
|
748
|
+
orch = None
|
|
749
|
+
orch_err = ""
|
|
750
|
+
if orch:
|
|
751
|
+
prov, model = orch
|
|
752
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
753
|
+
planner._log_usage("bookmeta", "bookmeta", task, res, model=model,
|
|
754
|
+
provider=prov.get("name", prov.get("id", "")),
|
|
755
|
+
provider_id=prov.get("id", ""))
|
|
756
|
+
if res["ok"]:
|
|
757
|
+
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
758
|
+
if meta:
|
|
759
|
+
meta = _fill_tag_gaps(task, platform, meta, outline)
|
|
760
|
+
meta = _fill_required_fields(task, platform, meta, outline, material)
|
|
761
|
+
return meta, _apply_fix_note(meta, "编排者(%s · %s)"
|
|
762
|
+
% (prov.get("name", prov["id"]), model))
|
|
763
|
+
orch_err = str(res.get("error") or "返回内容无法解析为作品信息")[:200]
|
|
764
|
+
|
|
765
|
+
if author_agent and author_agent.get("mode") == "real" and not template_only():
|
|
766
|
+
res = runner.run_agent(modelhub.bind_agent(author_agent), prompt,
|
|
767
|
+
workdir=task.get("workdir"), readonly=True,
|
|
768
|
+
timeout=BOOKMETA_TIMEOUT, log_path=log_path)
|
|
769
|
+
planner._log_usage("bookmeta", "bookmeta", task, res, agent=author_agent)
|
|
770
|
+
if res["ok"]:
|
|
771
|
+
meta = _norm(runner.extract_json(res.get("text") or ""), platform, goal)
|
|
772
|
+
if meta:
|
|
773
|
+
meta = _fill_tag_gaps(task, platform, meta, outline)
|
|
774
|
+
meta = _fill_required_fields(task, platform, meta, outline, material)
|
|
775
|
+
return meta, _apply_fix_note(meta, "llm(%s)" % author_agent["id"])
|
|
776
|
+
orch_err = orch_err or str(res.get("error") or "")[:200]
|
|
777
|
+
|
|
778
|
+
meta = _template_meta(task, platform, outline)
|
|
779
|
+
meta = _fill_required_fields(task, platform, meta, outline, material)
|
|
780
|
+
meta["source"] = ("模板兜底(编排者不可用:%s)" % orch_err) if orch_err else "模板兜底"
|
|
781
|
+
return meta, _apply_fix_note(meta, meta["source"])
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ---- 换名(2026-10-02 用户实案:生成的书名与本站已有作品重名,用户要的是
|
|
785
|
+
# 「换一个名字」而不是整套重摇——重摇会覆盖已核对过的分类/标签/简介)
|
|
786
|
+
|
|
787
|
+
RENAME_TIMEOUT = 120
|
|
788
|
+
# 排除名单注入上限:全站书名可能上百个,超出的按序丢弃(提示词预算保护;
|
|
789
|
+
# 排序后字母序前 80 个 + 调用方传入的近期撞名足够防撞,撞了还有重试兜底)
|
|
790
|
+
RENAME_EXCLUDE_LIMIT = 80
|
|
791
|
+
|
|
792
|
+
RENAME_PROMPT = """你是网文编辑。这本书《__OLD__》的书名与已有作品重名了,请另起一个新书名。
|
|
793
|
+
|
|
794
|
+
书的基本情况:
|
|
795
|
+
__MATERIAL__
|
|
796
|
+
|
|
797
|
+
硬性要求:
|
|
798
|
+
- 只输出一个 ```json 代码块:{"book_name": "新书名"},不要输出其他内容;
|
|
799
|
+
- 新书名不超过 15 个字,贴合题材、有网文点击欲,与旧书名同一题材但用词明显不同;
|
|
800
|
+
- 严禁与下面任何一本书重名,也不得只加「2」「新」「之」等后缀敷衍:
|
|
801
|
+
__EXCLUDE__"""
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def used_book_names():
|
|
805
|
+
"""本站全部已用书名(换名排除名单):已建书登记(books.json 的 title)
|
|
806
|
+
+ 所有任务 book_meta 各平台 done 态的 book_name。读取异常只降级不阻断
|
|
807
|
+
(名单不全最坏结果是换出来的名字仍撞,用户再点一次)。"""
|
|
808
|
+
from .publish import ledger
|
|
809
|
+
names = set()
|
|
810
|
+
try:
|
|
811
|
+
# books.json 结构:{task_id: {platform: {book_id, title, url, ...}}}
|
|
812
|
+
for by_plat in (ledger.load_books() or {}).values():
|
|
813
|
+
if not isinstance(by_plat, dict):
|
|
814
|
+
continue
|
|
815
|
+
for rec in by_plat.values():
|
|
816
|
+
t = str((rec or {}).get("title") or "").strip()
|
|
817
|
+
if t:
|
|
818
|
+
names.add(t)
|
|
819
|
+
except Exception:
|
|
820
|
+
pass
|
|
821
|
+
try:
|
|
822
|
+
from . import store
|
|
823
|
+
for task in store.list_tasks(limit=10 ** 9):
|
|
824
|
+
for entry in (task.get("book_meta") or {}).values():
|
|
825
|
+
if isinstance(entry, dict) and entry.get("status") == "done":
|
|
826
|
+
n = str((entry.get("data") or {}).get("book_name") or "").strip()
|
|
827
|
+
if n:
|
|
828
|
+
names.add(n)
|
|
829
|
+
except Exception:
|
|
830
|
+
pass
|
|
831
|
+
return names
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def rename_book(task, platform, old_name, exclude_names=()):
|
|
835
|
+
"""给已生成的作品信息换一个不重名的书名。返回 (new_name, "") 或 ("", 错误)。
|
|
836
|
+
|
|
837
|
+
只产出新名,不写盘——调用方负责更新 book_meta 与归档同步;这样模型失败
|
|
838
|
+
时现有数据原样保留(区别于整套生成的线程+状态机,轻调用同步返回即可)。
|
|
839
|
+
新名若仍撞排除名单,把撞名项并入名单重试一次。"""
|
|
840
|
+
from . import modelhub, planner, runner
|
|
841
|
+
old_name = str(old_name or "").strip()
|
|
842
|
+
exclude = {str(x).strip() for x in (exclude_names or ()) if str(x).strip()}
|
|
843
|
+
exclude.add(old_name)
|
|
844
|
+
goal_lines = [x.strip() for x in (task.get("goal") or "").splitlines() if x.strip()]
|
|
845
|
+
meta_lines = []
|
|
846
|
+
if goal_lines:
|
|
847
|
+
meta_lines.append("目标:" + goal_lines[0][:200])
|
|
848
|
+
try:
|
|
849
|
+
_, bible, _ = store_read_bible(task)
|
|
850
|
+
if bible:
|
|
851
|
+
meta_lines.append("故事圣经摘录:" + bible[:400])
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
material = "\n".join(meta_lines) if meta_lines else "(素材缺失,按旧书名题材另起)"
|
|
855
|
+
orch = None
|
|
856
|
+
try:
|
|
857
|
+
orch = modelhub.resolve_orchestrator()
|
|
858
|
+
except Exception:
|
|
859
|
+
orch = None
|
|
860
|
+
if not orch:
|
|
861
|
+
return "", "编排者模型不可用,无法自动换名(可稍后重试)"
|
|
862
|
+
prov, model = orch
|
|
863
|
+
last = ""
|
|
864
|
+
for _ in range(2): # 首轮撞名单 → 带撞名项重试一次
|
|
865
|
+
ex_block = "\n".join("- " + n for n in sorted(exclude)[:RENAME_EXCLUDE_LIMIT])
|
|
866
|
+
prompt = (RENAME_PROMPT.replace("__OLD__", old_name)
|
|
867
|
+
.replace("__MATERIAL__", material)
|
|
868
|
+
.replace("__EXCLUDE__", ex_block or "(暂无)"))
|
|
869
|
+
# max_tokens 4000 对齐主生成(2026-10-03 实案:glm-5.3 思考计入
|
|
870
|
+
# max_tokens,给 400 三次全被思考烧穿 output 打满,JSON 没成形)
|
|
871
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000,
|
|
872
|
+
timeout=RENAME_TIMEOUT)
|
|
873
|
+
planner._log_usage("bookmeta", "bookmeta-rename", task, res, model=model,
|
|
874
|
+
provider=prov.get("name", prov.get("id", "")),
|
|
875
|
+
provider_id=prov.get("id", ""))
|
|
876
|
+
if not res["ok"]:
|
|
877
|
+
last = str(res.get("error") or "模型调用失败")[:200]
|
|
878
|
+
continue
|
|
879
|
+
text = str(res.get("text") or "")
|
|
880
|
+
data = runner.extract_json(text)
|
|
881
|
+
new_name = _name(data.get("book_name") if isinstance(data, dict) else "", 15)
|
|
882
|
+
if not new_name:
|
|
883
|
+
# 兜底:思考烧预算时 JSON 可能没成形,正文里有《书名》/「书名」
|
|
884
|
+
# 形态就抠出来用——抠出的名字仍要过下面的排除名单检查
|
|
885
|
+
m = re.search(r"[《「]([^《》「」]{1,15})[》」]", text)
|
|
886
|
+
if m:
|
|
887
|
+
new_name = _name(m.group(1), 15)
|
|
888
|
+
if not new_name:
|
|
889
|
+
last = "返回内容无法解析出新书名"
|
|
890
|
+
continue
|
|
891
|
+
if new_name in exclude:
|
|
892
|
+
last = "新书名《%s》仍与本站已有作品重名" % new_name
|
|
893
|
+
exclude.add(new_name)
|
|
894
|
+
continue
|
|
895
|
+
return new_name, ""
|
|
896
|
+
return "", last or "换名失败,请稍后重试"
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def render_markdown(task, platform, meta):
|
|
900
|
+
"""落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
|
|
901
|
+
plat = PLATFORMS[platform]["label"]
|
|
902
|
+
lines = ["# 作品信息(%s)" % plat, "",
|
|
903
|
+
"> 任务:%s 生成于 %s 来源:%s" % (
|
|
904
|
+
task.get("title") or task.get("id") or "",
|
|
905
|
+
time.strftime("%Y-%m-%d %H:%M"), meta.get("source") or "")]
|
|
906
|
+
lines.append("")
|
|
907
|
+
for key, label in FIELD_LABELS[platform]:
|
|
908
|
+
v = meta.get(key)
|
|
909
|
+
if isinstance(v, list):
|
|
910
|
+
v = "、".join(v) if v else "(待补充)"
|
|
911
|
+
elif key == "summary":
|
|
912
|
+
v = (v or "").strip() or "(待补充)"
|
|
913
|
+
else:
|
|
914
|
+
v = str(v or "").strip() or "(待补充)"
|
|
915
|
+
lines.append("- **%s**:%s" % (label, v))
|
|
916
|
+
lines.append("")
|
|
917
|
+
lines.append("> 各字段可直接复制进 %s 建书表单;分类与标签均选自平台真实选项表。" % plat)
|
|
918
|
+
return "\n".join(lines)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def generate_async(task_id, platform, author_agent=None):
|
|
922
|
+
"""后台线程入口:跑生成并把终态写回任务 book_meta;成功时落工作目录归档。
|
|
923
|
+
|
|
924
|
+
状态机:running(路由置入)→ done / failed。任何异常都落 failed,不留悬挂。"""
|
|
925
|
+
from . import store
|
|
926
|
+
task = store.get_task(task_id)
|
|
927
|
+
if not task:
|
|
928
|
+
return
|
|
929
|
+
try:
|
|
930
|
+
meta, source = make_book_meta(task, platform, author_agent=author_agent)
|
|
931
|
+
entry = {"status": "done", "data": meta, "source": source,
|
|
932
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
933
|
+
fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
|
|
934
|
+
if str(fp.parent) and fp.parent.is_dir():
|
|
935
|
+
fp.write_text(render_markdown(task, platform, meta), encoding="utf-8")
|
|
936
|
+
entry["file"] = PLATFORMS[platform]["file"]
|
|
937
|
+
except Exception as e: # 兜底线程不能静默死掉
|
|
938
|
+
entry = {"status": "failed", "error": str(e)[:300],
|
|
939
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
940
|
+
cur = store.get_task(task_id)
|
|
941
|
+
if not cur:
|
|
942
|
+
return
|
|
943
|
+
prev = (cur.get("book_meta") or {}).get(platform) or {}
|
|
944
|
+
if prev.get("status") != "running": # 期间被删/被重置:丢弃结果
|
|
945
|
+
return
|
|
946
|
+
store.set_book_meta(task_id, platform, entry)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def recover_orphans():
|
|
950
|
+
"""启动收尸:后台生成线程随进程重启死掉后,落盘的 running 无人写终态——
|
|
951
|
+
前端永远「生成中」(按钮禁用)+ 接口幂等拒绝,卡死不可自愈。启动时把
|
|
952
|
+
遗留 running 统一改判 failed,交还「重试」按钮。返回改判条数。"""
|
|
953
|
+
from . import store
|
|
954
|
+
n = 0
|
|
955
|
+
for task in store.list_tasks(limit=10 ** 9):
|
|
956
|
+
for plat, entry in (task.get("book_meta") or {}).items():
|
|
957
|
+
if isinstance(entry, dict) and entry.get("status") == "running":
|
|
958
|
+
store.set_book_meta(task["id"], plat, {
|
|
959
|
+
"status": "failed",
|
|
960
|
+
"error": "生成随服务重启中断,请点重试",
|
|
961
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")})
|
|
962
|
+
n += 1
|
|
963
|
+
return n
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def repair_existing():
|
|
967
|
+
"""补齐历史已生成记录中的必填字段。
|
|
968
|
+
|
|
969
|
+
旧版本允许分类/主角为空并把结果标成 done;只要字段缺失就按当前官方
|
|
970
|
+
目录和已有素材补一次,已有非空字段保持不变。返回修复条数,供启动日志
|
|
971
|
+
和升级诊断使用。
|
|
972
|
+
"""
|
|
973
|
+
from . import store
|
|
974
|
+
repaired = 0
|
|
975
|
+
for task in store.list_tasks(limit=10 ** 9):
|
|
976
|
+
book_meta = task.get("book_meta") or {}
|
|
977
|
+
for platform in PLATFORMS:
|
|
978
|
+
entry = book_meta.get(platform) or {}
|
|
979
|
+
if entry.get("status") != "done" or not isinstance(entry.get("data"), dict):
|
|
980
|
+
continue
|
|
981
|
+
data = dict(entry["data"])
|
|
982
|
+
if platform == "fanqie":
|
|
983
|
+
reader = data.get("target_reader")
|
|
984
|
+
cats, _, _, _ = _fq_tables(reader if reader in ("男频", "女频") else "男频")
|
|
985
|
+
required_ok = (
|
|
986
|
+
data.get("category") in cats
|
|
987
|
+
and all(_has_value(data.get(key)) for key in (
|
|
988
|
+
"tags_theme", "tags_role", "tags_plot", "content_plot",
|
|
989
|
+
"content_emotion", "content_character", "content_world"))
|
|
990
|
+
and _has_value(data.get("protagonist_1"))
|
|
991
|
+
and _has_value(data.get("protagonist_2"))
|
|
992
|
+
and _summary_ok(data.get("summary")))
|
|
993
|
+
else:
|
|
994
|
+
from . import bookmeta_catalog as cat
|
|
995
|
+
reader = data.get("target_reader")
|
|
996
|
+
channel = cat.QIMAO_CATS.get(reader) or {}
|
|
997
|
+
main = data.get("category_main")
|
|
998
|
+
required_ok = (
|
|
999
|
+
main in channel
|
|
1000
|
+
and data.get("category_sub") in (channel.get(main) or [])
|
|
1001
|
+
and all(_has_value(data.get(key)) for key in (
|
|
1002
|
+
"tags_style", "tags_role", "tags_plot", "tags_bg"))
|
|
1003
|
+
and _has_value(data.get("protagonist_1"))
|
|
1004
|
+
and _has_value(data.get("protagonist_2"))
|
|
1005
|
+
and _summary_ok(data.get("summary")))
|
|
1006
|
+
# 频道失配也视为待修复(2026-09-24 实案:级联自洽的跨频道组合
|
|
1007
|
+
# 字段全齐,required_ok 挡不住,只能靠题材信号识别)
|
|
1008
|
+
anchor, _ = _reader_anchor(task, platform,
|
|
1009
|
+
{"summary": data.get("summary") or ""})
|
|
1010
|
+
if anchor and anchor != data.get("target_reader"):
|
|
1011
|
+
required_ok = False
|
|
1012
|
+
if required_ok:
|
|
1013
|
+
continue
|
|
1014
|
+
try:
|
|
1015
|
+
material, outline = _collect_material(task)
|
|
1016
|
+
fixed = _fill_required_fields(task, platform, data, outline, material)
|
|
1017
|
+
fixes = fixed.pop("_fixes", None) # 先清临时键再比幂等
|
|
1018
|
+
if fixed == entry["data"]:
|
|
1019
|
+
continue
|
|
1020
|
+
payload = dict(entry)
|
|
1021
|
+
payload["data"] = fixed
|
|
1022
|
+
note = "已补齐必填字段"
|
|
1023
|
+
if fixes:
|
|
1024
|
+
note += ";" + ";".join(str(x) for x in fixes)
|
|
1025
|
+
payload["source"] = (str(entry.get("source") or "") + ";" + note).lstrip(";")
|
|
1026
|
+
store.set_book_meta(task["id"], platform, payload)
|
|
1027
|
+
try:
|
|
1028
|
+
fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
|
|
1029
|
+
if fp.parent.is_dir():
|
|
1030
|
+
fp.write_text(render_markdown(task, platform, fixed), encoding="utf-8")
|
|
1031
|
+
except OSError:
|
|
1032
|
+
pass
|
|
1033
|
+
repaired += 1
|
|
1034
|
+
except Exception:
|
|
1035
|
+
continue
|
|
1036
|
+
return repaired
|