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.
Files changed (110) hide show
  1. package/CHANGELOG.md +520 -507
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +272 -217
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4483
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +96 -22
  62. package/app/core/runner.py +2050 -2019
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -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
- return str(v or "").strip()[:500]
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 _fill_required_fields(task, platform, meta, outline, material):
457
- """生成阶段的必填字段闸门。
458
-
459
- ``_norm`` 仍保持“表外值清空”的纯校验语义,供旧数据修复和单测使用;
460
- 这里仅在真正交付作品信息前补齐平台建书表单的必填项,避免 UI 展示一整
461
- 屏“待补充”而无法直接复制建书。所有分类/标签仍来自官方目录。
462
- """
463
- from . import bookmeta_catalog as cat
464
-
465
- material = str(material or "")
466
- names = _material_names(material)
467
- female_words = ("女频", "女生", "女主", "言情", "古言", "恋爱")
468
- female = any(x in ((task.get("goal") or "") + material) for x in female_words)
469
- defaults = ("沈知微", "顾砚川", "林知夏") if female else ("陆沉", "苏晚", "程野")
470
- if not _has_value(meta.get("protagonist_1")):
471
- meta["protagonist_1"] = names[0] if names else defaults[0]
472
- if not _has_value(meta.get("protagonist_2")):
473
- candidate = names[1] if len(names) > 1 else defaults[1]
474
- if candidate == meta["protagonist_1"]:
475
- candidate = next((name for name in defaults if name != meta["protagonist_1"]), defaults[2])
476
- meta["protagonist_2"] = candidate
477
-
478
- if platform == "fanqie":
479
- reader = meta.get("target_reader") or ("女频" if female else "男频")
480
- if reader not in ("男频", "女频"):
481
- reader = "女频" if female else "男频"
482
- meta["target_reader"] = reader
483
- cats, theme, role, plot = _fq_tables(reader)
484
- c_plot, c_emo, c_char, c_world = _fq_content_tables()
485
- if meta.get("category") not in cats:
486
- meta["category"] = _pick_option(
487
- material, cats,
488
- (("戍边", "古风世情"), ("边关", "古风世情"),
489
- ("军", "抗战谍战"), ("悬疑", "女频悬疑" if female else "悬疑脑洞"),
490
- ("种田", "种田"), ("都市", "都市脑洞")))
491
- groups = (("tags_theme", theme, 2), ("tags_role", role, 2),
492
- ("tags_plot", plot, 2), ("content_plot", c_plot, 4),
493
- ("content_emotion", c_emo, 2), ("content_character", c_char, 4),
494
- ("content_world", c_world, 1))
495
- else:
496
- reader = meta.get("target_reader") or ("女生" if female else "男生")
497
- if reader not in ("男生", "女生"):
498
- reader = "女生" if female else "男生"
499
- meta["target_reader"] = reader
500
- channel = cat.QIMAO_CATS.get(reader) or cat.QIMAO_CATS["男生"]
501
- main = meta.get("category_main")
502
- if main not in channel:
503
- main = _pick_option(
504
- material, list(channel),
505
- (("戍边", "古代言情"), ("边关", "古代言情"),
506
- ("古代", "古代言情"), ("军事", "军事"),
507
- ("军", "军事"), ("都市", "都市"),
508
- ("玄幻", "玄幻奇幻"), ("悬疑", "奇闻异事")))
509
- meta["category_main"] = main
510
- subs = channel.get(main) or []
511
- sub = meta.get("category_sub")
512
- if sub not in subs:
513
- located = cat.qimao_locate_sub(sub)
514
- if located:
515
- reader, main = located
516
- meta["target_reader"] = reader
517
- meta["category_main"] = main
518
- subs = cat.QIMAO_CATS[reader][main]
519
- else:
520
- sub = ""
521
- if sub not in subs:
522
- meta["category_sub"] = _pick_option(material, subs)
523
- groups = (("tags_style", cat.QIMAO_TAG_GROUPS["风格"], 3),
524
- ("tags_role", cat.QIMAO_TAG_GROUPS["角色"], 3),
525
- ("tags_plot", cat.QIMAO_TAG_GROUPS["情节"], 3),
526
- ("tags_bg", cat.QIMAO_TAG_GROUPS["背景"], 3))
527
- for key, table, limit in groups:
528
- values = meta.get(key)
529
- if not isinstance(values, list):
530
- values = []
531
- values = [str(v).strip() for v in values if str(v or "").strip() in table]
532
- if not values:
533
- values = [_pick_option(material, table)]
534
- meta[key] = values[:limit]
535
- return meta
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) + "\n\n## 小说素材\n" + material)
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
- else:
710
- from . import bookmeta_catalog as cat
711
- reader = data.get("target_reader")
712
- channel = cat.QIMAO_CATS.get(reader) or {}
713
- main = data.get("category_main")
714
- required_ok = (
715
- main in channel
716
- and data.get("category_sub") in (channel.get(main) or [])
717
- and all(_has_value(data.get(key)) for key in (
718
- "tags_style", "tags_role", "tags_plot", "tags_bg"))
719
- and _has_value(data.get("protagonist_1"))
720
- and _has_value(data.get("protagonist_2")))
721
- if required_ok:
722
- continue
723
- try:
724
- material, outline = _collect_material(task)
725
- fixed = _fill_required_fields(task, platform, data, outline, material)
726
- if fixed == entry["data"]:
727
- continue
728
- payload = dict(entry)
729
- payload["data"] = fixed
730
- payload["source"] = (str(entry.get("source") or "") + ";已补齐必填字段").lstrip(";")
731
- store.set_book_meta(task["id"], platform, payload)
732
- try:
733
- fp = Path(task.get("workdir") or "") / PLATFORMS[platform]["file"]
734
- if fp.parent.is_dir():
735
- fp.write_text(render_markdown(task, platform, fixed), encoding="utf-8")
736
- except OSError:
737
- pass
738
- repaired += 1
739
- except Exception:
740
- continue
741
- return repaired
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