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