codebee 0.1.71 → 0.1.73

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 (118) hide show
  1. package/CHANGELOG.md +601 -589
  2. package/LICENSE +21 -21
  3. package/README.md +508 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/aiflavor.py +197 -197
  6. package/app/core/artifacts.py +148 -148
  7. package/app/core/assets.py +110 -110
  8. package/app/core/attachments.py +623 -623
  9. package/app/core/audit.py +30 -30
  10. package/app/core/backup.py +470 -470
  11. package/app/core/beekeeper.py +183 -183
  12. package/app/core/benchstore.py +95 -95
  13. package/app/core/board.py +209 -209
  14. package/app/core/bookmeta.py +935 -935
  15. package/app/core/bookmeta_catalog.py +180 -180
  16. package/app/core/branching.py +137 -137
  17. package/app/core/builtin_agent.py +1680 -1673
  18. package/app/core/capability.py +144 -144
  19. package/app/core/chaptersafe.py +32 -32
  20. package/app/core/cleanup.py +303 -303
  21. package/app/core/compaction.py +201 -201
  22. package/app/core/covergen.py +356 -356
  23. package/app/core/defectretro.py +43 -43
  24. package/app/core/diagnostics.py +114 -114
  25. package/app/core/dispatch.py +190 -190
  26. package/app/core/dispatch_log.py +128 -128
  27. package/app/core/env_scrub.py +85 -85
  28. package/app/core/errorlog.py +137 -137
  29. package/app/core/evalbench.py +836 -720
  30. package/app/core/evaluation.py +174 -174
  31. package/app/core/failure_notify.py +290 -290
  32. package/app/core/flows.py +8 -8
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +374 -374
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +162 -162
  37. package/app/core/jobs.py +25 -1
  38. package/app/core/manager.py +2208 -2143
  39. package/app/core/market.py +90 -4
  40. package/app/core/market_remote.py +16 -0
  41. package/app/core/mocks.py +64 -64
  42. package/app/core/modelhub.py +3 -2
  43. package/app/core/notify.py +201 -201
  44. package/app/core/novel_quality.py +189 -189
  45. package/app/core/operations.py +228 -228
  46. package/app/core/paihang.py +145 -138
  47. package/app/core/pipeline.py +102 -30
  48. package/app/core/planner.py +2 -0
  49. package/app/core/plugins.py +34 -0
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/preview.py +293 -293
  52. package/app/core/prompt_guard.py +78 -0
  53. package/app/core/publish/__init__.py +7 -7
  54. package/app/core/publish/auto.py +396 -367
  55. package/app/core/publish/browser.py +655 -655
  56. package/app/core/publish/fanqie.py +165 -165
  57. package/app/core/publish/flow.py +640 -589
  58. package/app/core/publish/flows-fanqie-calibrated.json +336 -336
  59. package/app/core/publish/ledger.py +307 -229
  60. package/app/core/publish/manager.py +1118 -824
  61. package/app/core/publish/qimao.py +176 -146
  62. package/app/core/publish/ws.py +139 -139
  63. package/app/core/quality_gate.py +194 -0
  64. package/app/core/redact.py +38 -38
  65. package/app/core/repeat_guard.py +129 -129
  66. package/app/core/router.py +398 -398
  67. package/app/core/runner.py +2142 -2142
  68. package/app/core/selfupdate.py +384 -384
  69. package/app/core/session_log.py +161 -161
  70. package/app/core/sessions.py +312 -312
  71. package/app/core/settings_schema.py +270 -270
  72. package/app/core/share_page.py +114 -114
  73. package/app/core/skill_scan.py +139 -122
  74. package/app/core/skillpacks/fanqie-novel.md +98 -80
  75. package/app/core/skillpacks/market/character-bible.md +66 -66
  76. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  77. package/app/core/skillpacks/market/git-workflow.md +57 -57
  78. package/app/core/skillpacks/market/release-notes.md +72 -72
  79. package/app/core/skillpacks/market/weekly-report.md +71 -71
  80. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  81. package/app/core/skillpacks/qimao-signing.md +70 -59
  82. package/app/core/skills.py +58 -8
  83. package/app/core/step_runner.py +132 -132
  84. package/app/core/store.py +51 -0
  85. package/app/core/task_compile.py +199 -199
  86. package/app/core/telemetry.py +292 -292
  87. package/app/core/tlsctx.py +64 -64
  88. package/app/core/token_meter.py +147 -147
  89. package/app/core/tracing.py +144 -144
  90. package/app/core/upstream.py +41 -41
  91. package/app/core/usage.py +1035 -1035
  92. package/app/core/volumes.py +389 -389
  93. package/app/core/wxdigest.py +858 -858
  94. package/app/core/zentao.py +2692 -2614
  95. package/app/main.py +110 -39
  96. package/app/mcp_server.py +175 -175
  97. package/app/pick_dialog.py +110 -110
  98. package/app/ui/app.js +509 -114
  99. package/app/ui/artdiff.js +74 -74
  100. package/app/ui/board.html +162 -162
  101. package/app/ui/board.js +305 -305
  102. package/app/ui/i18n.js +41 -2
  103. package/app/ui/icons/bee.svg +79 -79
  104. package/app/ui/index.html +8 -3
  105. package/app/ui/manifest.json +16 -16
  106. package/app/ui/qrcode.js +2297 -2297
  107. package/app/ui/style.css +74 -26
  108. package/bin/tutti.js +147 -147
  109. package/package.json +48 -47
  110. package/tools/wx_reader.py +261 -261
  111. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  112. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  113. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  114. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  115. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  116. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  117. package/app/.playwright-cli/run-detail-after.png +0 -0
  118. package/app//344/275/234/345/223/201/344/277/241/346/201/257-/344/270/203/347/214/253.md +0 -18
@@ -1,138 +1,145 @@
1
- # -*- coding: utf-8 -*-
2
- """扫榜选材:抓取公开排行榜页,产出选题分析素材。
3
-
4
- 数据源(四源聚合,任一失败不影响其余源;2026-09-24 实测全 200):
5
- - 七猫:www.qimao.com/paihang/(106KB)
6
- - 番茄 Web 版:fanqienovel.com/rank(题材分类+书名在中文 a 标签里;
7
- App 端接口有 SecuritySign/X-Argus 签名——那才是难路,Web 版榜单页
8
- 不需要签名,别走弯路)
9
- - 起点移动版:m.qidian.com/rank(106KB,直接出书名;主站 www.qidian.com
10
- 被 WAF 拦 202/209B,移动页不需要签名)
11
- - 纵横:www.zongheng.com/rank(65KB,书名+统计标签混排,靠噪音词过滤)
12
-
13
- 下载走 curl 子进程(与 covergen 同款),解析用宽松正则——页面结构变了
14
- 宁可返回空(调用方回落普通直连提示词),不做脆弱的强解析。
15
- """
16
- from __future__ import annotations
17
-
18
- import logging
19
- import re
20
-
21
- log = logging.getLogger(__name__)
22
-
23
- QIMAO_RANK_URL = "https://www.qimao.com/paihang/"
24
- FANQIE_RANK_URL = "https://fanqienovel.com/rank"
25
- QIDIAN_M_RANK_URL = "https://m.qidian.com/rank/"
26
- ZONGHENG_RANK_URL = "https://www.zongheng.com/rank/"
27
- _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
28
- "(KHTML, like Gecko) Chrome/126 Safari/537.36")
29
-
30
- # 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索、
31
- # 榜单 tab 与统计标签(起点移动版 tab 名、纵横月票/人气/点击等)
32
- _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
33
- "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
34
- "帮助中心", "作家助手", "客服", "登录", "注册", "立即登录",
35
- "搜索", "热门作品排行", "大热榜", "新书榜", "完结榜", "收藏榜",
36
- "更新榜", "日榜", "月榜", "蝉联", "榜首", "热度", "免费", "畅销",
37
- "下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于",
38
- "月票榜", "阅读榜", "书友榜", "推荐榜", "签约榜", "新人榜",
39
- "畅销榜", "大家都在搜", "男生小说排行榜", "女生小说排行榜",
40
- "男频阅读榜", "女频阅读榜", "男频新书榜", "番茄小说",
41
- "登录后获得更多特色功能", "安装起点读书客户端", "看更多正版好书",
42
- "月票", "人气", "点击", "万字", "推荐", "收藏", "鲜花", "打赏", "推荐票"}
43
-
44
- _CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
45
-
46
-
47
- def _curl_text(url):
48
- """curl 子进程抓页面文本;失败返回空串。"""
49
- from . import runner
50
- try:
51
- r = runner.run_process(
52
- argv=["curl", "-sS", "--max-time", "20", "-A", _UA, url],
53
- timeout=30)
54
- except Exception:
55
- return ""
56
- if not r.get("ok"):
57
- return ""
58
- return r.get("stdout") or ""
59
-
60
-
61
- def _extract_cjk(html, limit):
62
- """从 HTML 提取中文元素材(去 UI 噪音、保序去重)。"""
63
- items, seen = [], set()
64
- for t in _CJK_RE.findall(html or ""):
65
- if t in _NOISE or t in seen:
66
- continue
67
- seen.add(t)
68
- items.append(t)
69
- if len(items) >= limit:
70
- break
71
- return items
72
-
73
-
74
- def fetch_qimao_rank(limit=30):
75
- """七猫排行榜:返回元素列表,抓取失败返回 []。"""
76
- return _extract_cjk(_curl_text(QIMAO_RANK_URL), limit)
77
-
78
-
79
- def fetch_fanqie_rank(limit=30):
80
- """番茄 Web 版榜单:返回元素列表,抓取失败返回 []。"""
81
- return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
82
-
83
-
84
- def fetch_qidian_rank(limit=30):
85
- """起点移动版榜单:返回元素列表,抓取失败返回 []。"""
86
- return _extract_cjk(_curl_text(QIDIAN_M_RANK_URL), limit)
87
-
88
-
89
- def fetch_zongheng_rank(limit=30):
90
- """纵横排行榜:返回元素列表,抓取失败返回 []。"""
91
- return _extract_cjk(_curl_text(ZONGHENG_RANK_URL), limit)
92
-
93
-
94
- # 源清单:顺序即素材拼接顺序(七猫/番茄为元老源保持在前)。
95
- # 存函数名、调用期 globals() 解析——mock.patch 换模块属性才能生效
96
- # (存函数引用会把定义期原函数钉死,测试会穿透打真网络)。
97
- _SOURCES = (
98
- ("七猫排行榜素材", "fetch_qimao_rank"),
99
- ("番茄排行榜素材", "fetch_fanqie_rank"),
100
- ("起点排行榜素材", "fetch_qidian_rank"),
101
- ("纵横排行榜素材", "fetch_zongheng_rank"),
102
- )
103
-
104
-
105
- def fetch_rank_items(limit=40):
106
- """四源聚合:各抓一份(跨源去重合并,按源清单顺序)。
107
-
108
- 全部失败才返回 []——单源挂了其余源仍可用,扫榜永不因单点挡任务。"""
109
- merged, seen = [], set()
110
- for _label, fname in _SOURCES:
111
- for t in globals()[fname](limit):
112
- if t not in seen:
113
- seen.add(t)
114
- merged.append(t)
115
- return merged
116
-
117
-
118
- def rank_scan_prompt(goal):
119
- """组装扫榜选材分析提示词。四源全空返回 None(调用方回落直连提示词)。"""
120
- blocks, seen = [], set()
121
- for label, fname in _SOURCES:
122
- items = [t for t in globals()[fname](30) if t not in seen]
123
- seen.update(items)
124
- if items:
125
- blocks.append("### %s\n%s" % (label, "、".join(items)))
126
- if not blocks:
127
- return None
128
- material = "\n\n".join(blocks)
129
- return (
130
- "你是网文选题分析师。以下是刚刚抓取的各平台排行榜页面的书目与分类素材:\n\n"
131
- "## 榜单元素材\n%s\n\n"
132
- "## 用户想写的方向\n%s\n\n"
133
- "## 你的产出(Markdown 报告)\n"
134
- "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——多平台都热说明是真风口)\n"
135
- "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
136
- "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
137
- "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
138
- % (material, goal or "(用户未指定方向,按大盘热门分析)"))
1
+ # -*- coding: utf-8 -*-
2
+ """扫榜选材:抓取公开排行榜页,产出选题分析素材。
3
+
4
+ 数据源(四源聚合,任一失败不影响其余源;2026-09-24 实测全 200):
5
+ - 七猫:www.qimao.com/paihang/(106KB)
6
+ - 番茄 Web 版:fanqienovel.com/rank(题材分类+书名在中文 a 标签里;
7
+ App 端接口有 SecuritySign/X-Argus 签名——那才是难路,Web 版榜单页
8
+ 不需要签名,别走弯路)
9
+ - 起点移动版:m.qidian.com/rank(106KB,直接出书名;主站 www.qidian.com
10
+ 被 WAF 拦 202/209B,移动页不需要签名)
11
+ - 纵横:www.zongheng.com/rank(65KB,书名+统计标签混排,靠噪音词过滤)
12
+
13
+ 下载走 curl 子进程(与 covergen 同款),解析用宽松正则——页面结构变了
14
+ 宁可返回空(调用方回落普通直连提示词),不做脆弱的强解析。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import re
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ QIMAO_RANK_URL = "https://www.qimao.com/paihang/"
24
+ FANQIE_RANK_URL = "https://fanqienovel.com/rank"
25
+ QIDIAN_M_RANK_URL = "https://m.qidian.com/rank/"
26
+ ZONGHENG_RANK_URL = "https://www.zongheng.com/rank/"
27
+ _UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
28
+ "(KHTML, like Gecko) Chrome/126 Safari/537.36")
29
+
30
+ # 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索、
31
+ # 榜单 tab 与统计标签(起点移动版 tab 名、纵横月票/人气/点击等)
32
+ _NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
33
+ "排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
34
+ "帮助中心", "作家助手", "客服", "登录", "注册", "立即登录",
35
+ "搜索", "热门作品排行", "大热榜", "新书榜", "完结榜", "收藏榜",
36
+ "更新榜", "日榜", "月榜", "蝉联", "榜首", "热度", "免费", "畅销",
37
+ "下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于",
38
+ "月票榜", "阅读榜", "书友榜", "推荐榜", "签约榜", "新人榜",
39
+ "畅销榜", "大家都在搜", "男生小说排行榜", "女生小说排行榜",
40
+ "男频阅读榜", "女频阅读榜", "男频新书榜", "番茄小说",
41
+ "登录后获得更多特色功能", "安装起点读书客户端", "看更多正版好书",
42
+ "月票", "人气", "点击", "万字", "推荐", "收藏", "鲜花", "打赏", "推荐票"}
43
+
44
+ _CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
45
+
46
+
47
+ def _curl_text(url):
48
+ """curl 子进程抓页面文本;失败返回空串。"""
49
+ from . import runner
50
+ try:
51
+ r = runner.run_process(
52
+ argv=["curl", "-sS", "--max-time", "20", "-A", _UA, url],
53
+ timeout=30)
54
+ except Exception:
55
+ return ""
56
+ if not r.get("ok"):
57
+ return ""
58
+ return r.get("stdout") or ""
59
+
60
+
61
+ def _extract_cjk(html, limit):
62
+ """从 HTML 提取中文元素材(去 UI 噪音、保序去重)。"""
63
+ items, seen = [], set()
64
+ for t in _CJK_RE.findall(html or ""):
65
+ if t in _NOISE or t in seen:
66
+ continue
67
+ seen.add(t)
68
+ items.append(t)
69
+ if len(items) >= limit:
70
+ break
71
+ return items
72
+
73
+
74
+ def fetch_qimao_rank(limit=30):
75
+ """七猫排行榜:返回元素列表,抓取失败返回 []。"""
76
+ return _extract_cjk(_curl_text(QIMAO_RANK_URL), limit)
77
+
78
+
79
+ def fetch_fanqie_rank(limit=30):
80
+ """番茄 Web 版榜单:返回元素列表,抓取失败返回 []。"""
81
+ return _extract_cjk(_curl_text(FANQIE_RANK_URL), limit)
82
+
83
+
84
+ def fetch_qidian_rank(limit=30):
85
+ """起点移动版榜单:返回元素列表,抓取失败返回 []。"""
86
+ return _extract_cjk(_curl_text(QIDIAN_M_RANK_URL), limit)
87
+
88
+
89
+ def fetch_zongheng_rank(limit=30):
90
+ """纵横排行榜:返回元素列表,抓取失败返回 []。"""
91
+ return _extract_cjk(_curl_text(ZONGHENG_RANK_URL), limit)
92
+
93
+
94
+ # 源清单:顺序即素材拼接顺序(七猫/番茄为元老源保持在前)。
95
+ # 存函数名、调用期 globals() 解析——mock.patch 换模块属性才能生效
96
+ # (存函数引用会把定义期原函数钉死,测试会穿透打真网络)。
97
+ _SOURCES = (
98
+ ("七猫排行榜素材", "fetch_qimao_rank"),
99
+ ("番茄排行榜素材", "fetch_fanqie_rank"),
100
+ ("起点排行榜素材", "fetch_qidian_rank"),
101
+ ("纵横排行榜素材", "fetch_zongheng_rank"),
102
+ )
103
+
104
+
105
+ def fetch_rank_items(limit=40):
106
+ """四源聚合:各抓一份(跨源去重合并,按源清单顺序)。
107
+
108
+ 全部失败才返回 []——单源挂了其余源仍可用,扫榜永不因单点挡任务。"""
109
+ merged, seen = [], set()
110
+ for _label, fname in _SOURCES:
111
+ for t in globals()[fname](limit):
112
+ if t not in seen:
113
+ seen.add(t)
114
+ merged.append(t)
115
+ return merged
116
+
117
+
118
+ def rank_scan_prompt(goal):
119
+ """组装扫榜选材分析提示词。四源全空返回 None(调用方回落直连提示词)。
120
+
121
+ 报告契约含逐条 A-E 跨平台证据分级(借鉴 career-ops 的分级报告交付形态):
122
+ 分级只认素材实际出现情况——抑制单源孤证被模型放大成「大热题材」。"""
123
+ blocks, seen = [], set()
124
+ for label, fname in _SOURCES:
125
+ items = [t for t in globals()[fname](30) if t not in seen]
126
+ seen.update(items)
127
+ if items:
128
+ blocks.append("### %s\n%s" % (label, "、".join(items)))
129
+ if not blocks:
130
+ return None
131
+ material = "\n\n".join(blocks)
132
+ return (
133
+ "你是网文选题分析师。以下是刚刚抓取的各平台排行榜页面的书目与分类素材:\n\n"
134
+ "## 榜单元素材\n%s\n\n"
135
+ "## 用户想写的方向\n%s\n\n"
136
+ "## 你的产出(Markdown 报告)\n"
137
+ "报告第一行给图例(一行):「证据分级:A=三平台及以上重合,B=两平台重合,"
138
+ "C=单平台多次出现,D=单平台仅一次,E=素材无直接证据、系推断」。\n"
139
+ "1. **热门题材 Top3**:各自的共同特征与上榜代表书目(跨平台重合的题材单独点出——多平台都热说明是真风口)\n"
140
+ "2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
141
+ "3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
142
+ "以上每一项结论末尾标注【证据X】(X 为 A-E 之一)。分级只看素材里真实出现的"
143
+ "情况:组合条目按其组成元素的较低档取值,拿不准就降一档、不升级。\n"
144
+ "直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
145
+ % (material, goal or "(用户未指定方向,按大盘热门分析)"))
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, attachments, branching, catalog, chaptersafe, defectretro, dispatch_log, flows, history, hooks, jobs, knowledge, manager, modelhub, mocks, novel_quality, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
22
+ from . import aiflavor, attachments, branching, catalog, chaptersafe, defectretro, dispatch_log, flows, history, hooks, jobs, knowledge, manager, modelhub, mocks, novel_quality, paihang, planner, quality_gate, registry, router, runner, skills, store, task_compile, usage, volumes
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -2636,16 +2636,34 @@ def _critique_json(res, dims):
2636
2636
  任何一道出分即算有效评审——「无法解析」绝不能把正常出分的评审吞掉
2637
2637
  (2026-09-18 七猫案:kimi 内嵌引号病连烧三轮自动续跑全判评审全挂)。"""
2638
2638
  text = res.get("text") or ""
2639
+ def validated(value):
2640
+ value = value if isinstance(value, dict) else {}
2641
+ raw_scores = value.get("scores") if isinstance(value.get("scores"), dict) else {}
2642
+ scores, errors = quality_gate.normalize_scores(raw_scores, dims)
2643
+ if errors:
2644
+ # Keep individually valid dimensions for repair/polish diagnostics,
2645
+ # but mark the set incomplete so release consensus cannot use it.
2646
+ partial = {}
2647
+ for dim in dims:
2648
+ one, one_errors = quality_gate.normalize_scores({dim: raw_scores.get(dim)}, [dim])
2649
+ if not one_errors:
2650
+ partial[dim] = one[dim]
2651
+ scores = partial
2652
+ value["scores"] = scores
2653
+ if errors:
2654
+ value["score_errors"] = errors
2655
+ return value
2656
+
2639
2657
  gj = runner.as_scores(runner.extract_json(text))
2640
2658
  if isinstance(gj, dict) and isinstance(gj.get("scores"), dict) and gj.get("scores"):
2641
- return gj
2659
+ return validated(gj)
2642
2660
  prose = runner.scores_from_prose(text, dims)
2643
2661
  if not prose:
2644
2662
  # 第四道网(BAML 借鉴):维度名没命中时按「X:N 分」模式泛化抓取——
2645
2663
  # 自定义 rubric 改了维度措辞而模型用了自己的说法时仍能救回
2646
2664
  prose = runner.extract_scores_from_text(text)
2647
2665
  if prose:
2648
- return {"scores": prose, "issues": [], "summary": text[:400]}
2666
+ return validated({"scores": prose, "issues": [], "summary": text[:400]})
2649
2667
  return {"scores": {}, "issues": [],
2650
2668
  "summary": "评审输出无法解析:%s" % (text or res.get("error") or "")[:150]}
2651
2669
 
@@ -3025,6 +3043,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3025
3043
 
3026
3044
  chapter_scores = [] # [{chapter,title,means,passed,rounds,words}]
3027
3045
  issues_all = []
3046
+ signing_review_meta = {}
3028
3047
 
3029
3048
  # ---- 独立签约评估设施(开篇闸门 + 2万字检查点共用)----
3030
3049
  def _signing_eval(upto_chapter, note):
@@ -3048,23 +3067,30 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3048
3067
  .replace("__DIMKEYS__", signing_dimkey)
3049
3068
  .replace("__NOTE__", note or "")
3050
3069
  .replace("__MANUSCRIPT__", "\n\n".join(texts)[:26000]))
3051
- agent = critics[0] if critics else impl
3052
- res = _run_step(run_id, "signing-eval", modelhub.bind_agent(agent, difficulty),
3053
- prompt, workdir, readonly=True, ev=ev, timeout=2400,
3054
- note=("签约评估 %s" % (note or "")).strip())
3055
- cj = _critique_json(res, novel_quality.SIGNING_RUBRIC)
3056
- scores = cj.get("scores") or {}
3057
- means = {}
3058
- for d in novel_quality.SIGNING_RUBRIC:
3059
- try:
3060
- means[d] = float(scores[d])
3061
- except (KeyError, TypeError, ValueError):
3062
- pass
3063
- if not means:
3070
+ reviewers = critics or [impl]
3071
+ reviews, summaries, issues = [], [], []
3072
+ for idx, agent in enumerate(reviewers):
3073
+ res = _run_step(run_id, "signing-eval", modelhub.bind_agent(agent, difficulty),
3074
+ prompt, workdir, readonly=True, ev=ev, timeout=2400,
3075
+ note=("签约评估 %s" % (note or "")).strip())
3076
+ cj = _critique_json(res, novel_quality.SIGNING_RUBRIC)
3077
+ if cj.get("scores"):
3078
+ reviews.append({"id": agent.get("id") or "signing-%d" % idx,
3079
+ "scores": cj.get("scores")})
3080
+ summaries.append(str(cj.get("summary") or ""))
3081
+ issues.extend((cj.get("issues") or [])[:6])
3082
+ agg = quality_gate.aggregate_reviews(
3083
+ reviews, novel_quality.SIGNING_RUBRIC, threshold=threshold,
3084
+ min_reviewers=1)
3085
+ signing_review_meta.clear()
3086
+ signing_review_meta.update({"reviewer_count": agg.get("reviewer_count", 0),
3087
+ "consensus": agg.get("consensus", False),
3088
+ "spreads": agg.get("spreads", {})})
3089
+ means = agg.get("means") or {}
3090
+ if not agg.get("eligible") or not means:
3064
3091
  return None, [], ""
3065
- issues = (cj.get("issues") or [])[:6]
3066
3092
  issues_all.extend({"chapter": upto_chapter, **it} for it in issues)
3067
- return means, issues, str(cj.get("summary") or "")
3093
+ return means, issues, next((s for s in summaries if s), "")
3068
3094
 
3069
3095
  def _signing_record(kind, payload):
3070
3096
  """签约评估结果入 run(signing_evals.<kind>),审计与 UI 证据。"""
@@ -3265,9 +3291,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3265
3291
  note="小说第 %d 章" % i,
3266
3292
  event_check=event_check, chapter=i) + note_extra,
3267
3293
  workdir, readonly=True, ev=ev)
3268
- cj = runner.extract_json(res.get("text") or "")
3294
+ cj = _critique_json(res, dims)
3269
3295
  if isinstance(cj, dict) and isinstance(cj.get("scores"), dict) \
3270
- and cj.get("scores"):
3296
+ and len(cj.get("scores") or {}) == len(dims):
3271
3297
  cj_map[spare["id"]] = cj
3272
3298
  scored += 1
3273
3299
  remember_agent(actual_critics, spare)
@@ -3283,11 +3309,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3283
3309
  def means_of(cj_map):
3284
3310
  vals = {}
3285
3311
  for d in dims:
3286
- xs = [float(cj["scores"].get(d, 0)) for cj in cj_map.values()
3287
- if isinstance(cj.get("scores"), dict) and d in cj["scores"]]
3312
+ xs = []
3313
+ for cj in cj_map.values():
3314
+ scores, errors = quality_gate.normalize_scores(cj.get("scores"), dims)
3315
+ if not errors and d in scores:
3316
+ xs.append(scores[d])
3288
3317
  vals[d] = round(sum(xs) / len(xs), 1) if xs else 0.0
3289
3318
  return vals
3290
3319
 
3320
+ def review_stats(cj_map, threshold_value):
3321
+ reviews = [{"id": aid, "scores": cj.get("scores")}
3322
+ for aid, cj in cj_map.items()]
3323
+ return quality_gate.aggregate_reviews(
3324
+ reviews, dims, threshold=threshold_value)
3325
+
3291
3326
  critic_sids = {} # §07 T1.1:每评审的会话 id(第 2 轮复用,前缀走缓存读)
3292
3327
  race_cj = None # 变体赛马已评审胜者:直接作为第 1 轮结果,不重评
3293
3328
  race_scored = 0
@@ -3664,6 +3699,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3664
3699
  "已中止以免以 0 分误判质量" % i, ended_at=_now())
3665
3700
  return
3666
3701
  means = means_of(cj_by_agent)
3702
+ chapter_review = review_stats(cj_by_agent, threshold_ch)
3667
3703
  passed = bool(means) and all(v >= threshold_ch for v in means.values())
3668
3704
  if rnd == 1 and gate_live:
3669
3705
  s_means, s_issues, s_summary = _signing_eval(
@@ -3787,6 +3823,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3787
3823
  return
3788
3824
  cs_new = {"chapter": i, "title": ch["title"], "means": means,
3789
3825
  "passed": bool(means) and all(v >= threshold_ch for v in means.values()),
3826
+ "reviewer_count": chapter_review.get("reviewer_count", 0),
3827
+ "reviewer_consensus": chapter_review.get("consensus", False),
3828
+ "reviewer_spreads": chapter_review.get("spreads", {}),
3790
3829
  "rounds": rounds_used,
3791
3830
  "words": _wc(_read_chapter(workdir, i)),
3792
3831
  "quality_signals": novel_quality.signal_summary(
@@ -3839,6 +3878,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3839
3878
  i, "(2万字签约预评估检查点)")
3840
3879
  cp = {"at_words": total_words, "chapter": i,
3841
3880
  "means": cp_means or {},
3881
+ "reviewer_count": signing_review_meta.get("reviewer_count", 0),
3882
+ "reviewer_consensus": signing_review_meta.get("consensus", False),
3883
+ "reviewer_spreads": signing_review_meta.get("spreads", {}),
3842
3884
  "passed": bool(cp_means) and all(
3843
3885
  v >= threshold for v in cp_means.values()) if cp_means else None,
3844
3886
  "summary": (cp_summary or "")[:200]}
@@ -3882,6 +3924,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3882
3924
  i, "(2万字检查点复核)")
3883
3925
  if cp_means2:
3884
3926
  cp["means"] = cp_means2
3927
+ cp["reviewer_count"] = signing_review_meta.get("reviewer_count", 0)
3928
+ cp["reviewer_consensus"] = signing_review_meta.get("consensus", False)
3929
+ cp["reviewer_spreads"] = signing_review_meta.get("spreads", {})
3885
3930
  cp["summary"] = (cp_sum2 or "")[:200]
3886
3931
  cp["passed"] = all(v >= threshold for v in cp_means2.values())
3887
3932
  store.update_run(run_id, signing_checkpoint=cp)
@@ -3889,13 +3934,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3889
3934
  # ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
3890
3935
  full_text = "\n\n".join(_read_chapter(workdir, i) for i in range(1, end + 1))
3891
3936
  global_issues = []
3937
+ global_review_results = []
3892
3938
 
3893
3939
  def run_global_round(agent_list):
3894
3940
  """一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。
3895
3941
 
3896
3942
  多评审并发(2026-09-22,同章级评审并发):各自读同一份全书文本、互不依赖,
3897
3943
  串行只是把等待时间叠起来。共享结构仍在 join 后按原顺序合并,结果与串行一致。"""
3898
- gmeans_acc, scored = {}, 0
3944
+ gmeans_acc, scored, valid_reviews = {}, 0, []
3899
3945
  results = {}
3900
3946
  # 绑定解析提前到主线程(线程内不做会写盘的 bind_agent)
3901
3947
  bound = [modelhub.bind_agent(a, difficulty) for a in agent_list]
@@ -3944,16 +3990,21 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3944
3990
  continue
3945
3991
  if "error" in gj:
3946
3992
  raise gj["error"] # 还原串行版的异常上抛语义
3947
- if gj.get("scores"):
3993
+ scores, errors = quality_gate.normalize_scores(gj.get("scores"), dims)
3994
+ partial_scores = gj.get("scores") if isinstance(gj.get("scores"), dict) else {}
3995
+ if scores or partial_scores:
3948
3996
  scored += 1
3997
+ if not errors:
3998
+ valid_reviews.append({"id": agent_list[idx].get("id") or "reviewer-%d" % idx,
3999
+ "scores": scores})
3949
4000
  global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
3950
4001
  for d in dims:
3951
- v = gj.get("scores", {}).get(d)
4002
+ v = scores.get(d) if not errors else partial_scores.get(d)
3952
4003
  if v is not None:
3953
4004
  gmeans_acc.setdefault(d, []).append(float(v))
3954
- return scored, gmeans_acc
4005
+ return scored, gmeans_acc, valid_reviews
3955
4006
 
3956
- gscored, gmeans_acc = run_global_round(critics)
4007
+ gscored, gmeans_acc, global_review_results = run_global_round(critics)
3957
4008
  # 「评不上」≠「评了低分」:全局评审全挂时先从其它真实智能体补位(对齐章级
3958
4009
  # 评审者级 fallback);补位后仍零分则判 run 失败——「无法评审」绝不能当成
3959
4010
  # 「全局评审未通过」去盖「未达标」章(2026-09-18 假未达标案:codex 绑定链
@@ -3963,7 +4014,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3963
4014
  tried = {a.get("id") for a in critics}
3964
4015
  for spare in [a for a in (agents or [])
3965
4016
  if a.get("mode") == "real" and a.get("id") not in tried][:2]:
3966
- sc, acc = run_global_round([spare])
4017
+ sc, acc, spare_reviews = run_global_round([spare])
4018
+ global_review_results.extend(spare_reviews)
3967
4019
  for d, xs in acc.items():
3968
4020
  gmeans_acc.setdefault(d, []).extend(xs)
3969
4021
  gscored += sc
@@ -3982,6 +4034,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
3982
4034
  return
3983
4035
  global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in gmeans_acc.items()}
3984
4036
  global_pass = _all_ge(global_means, threshold)
4037
+ global_review = quality_gate.aggregate_reviews(
4038
+ global_review_results, dims, threshold=threshold)
3985
4039
 
3986
4040
  # ---- 3.5) 自驱打磨:全局评审不过 → 自动重改最弱章并重评(至多 2 轮,无需人工)
3987
4041
  polish_rounds = 0
@@ -4079,14 +4133,17 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4079
4133
  # 不代表书变差,不能拿「无法评审」覆盖真实分数)
4080
4134
  try:
4081
4135
  full_text = "\n\n".join(_read_chapter(workdir, i2) for i2 in range(1, end + 1))
4082
- gscored2, gmeans_acc2 = run_global_round(critics)
4136
+ gscored2, gmeans_acc2, global_review_results2 = run_global_round(critics)
4083
4137
  except BaseException:
4084
4138
  store.finish_step(run_id, pstep["n"], "failed",
4085
4139
  summary="打磨后全书重评异常中止,已落盘章稿不受影响")
4086
4140
  raise
4087
4141
  if gscored2:
4142
+ global_review_results = global_review_results2
4088
4143
  global_means = {d: round(sum(xs) / len(xs), 1) for d, xs in gmeans_acc2.items()}
4089
4144
  global_pass = _all_ge(global_means, threshold)
4145
+ global_review = quality_gate.aggregate_reviews(
4146
+ global_review_results, dims, threshold=threshold)
4090
4147
  store.finish_step(run_id, pstep["n"], "done" if global_pass else "failed",
4091
4148
  summary="重改 %s;打磨后全局 %s(%s)" % (
4092
4149
  "、".join("第 %d 章" % x for x in fixed),
@@ -4125,7 +4182,16 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4125
4182
  duration_s=0.1)
4126
4183
 
4127
4184
  chapters_pass = all(c["passed"] for c in chapter_scores)
4128
- publishable = bool(chapters_pass and global_pass)
4185
+ major_issues = [x for x in issues_all + global_issues
4186
+ if isinstance(x, dict) and x.get("severity") == "major"]
4187
+ publishable = bool(chapters_pass and global_pass and not major_issues)
4188
+ signing_checkpoint = (store.get_run(run_id) or {}).get("signing_checkpoint") or {}
4189
+ quality_status = "continue_only"
4190
+ if publishable and global_review.get("consensus") and all(
4191
+ c.get("reviewer_count", 0) >= quality_gate.MIN_REVIEWERS and
4192
+ c.get("reviewer_consensus") is True for c in chapter_scores):
4193
+ quality_status = ("signing_ready" if signing_checkpoint.get("passed") is True
4194
+ else "publish_ready")
4129
4195
  overall = round(sum(sum(c["means"].values()) / max(1, len(c["means"]))
4130
4196
  for c in chapter_scores) / max(1, len(chapter_scores)), 1)
4131
4197
  verdict = {
@@ -4135,6 +4201,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
4135
4201
  "start_chapter": start, "end_chapter": end, "total_words": total_words,
4136
4202
  "chapter_scores": chapter_scores, "global_scores": global_means,
4137
4203
  "global_pass": global_pass, "route": route,
4204
+ "global_reviewer_count": global_review.get("reviewer_count", 0),
4205
+ "global_reviewer_consensus": global_review.get("consensus", False),
4206
+ "global_reviewer_spreads": global_review.get("spreads", {}),
4207
+ "major_issues": major_issues[:20],
4208
+ "signing_checkpoint": signing_checkpoint,
4209
+ "quality_status": quality_status,
4138
4210
  }
4139
4211
  if vol_plan:
4140
4212
  verdict["volumes"] = vol_plan
@@ -256,6 +256,8 @@ __SKILLS__
256
256
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
257
257
  {"book_title": "书名", "style_anchor": "全书文风锚(80字内,具体可执行:叙述视角与人称、句长基调、语域、对白风格、禁用表达)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)", "highlight": "本章爽点/情绪爆点(一句话:谁在哪里完成什么反转或释放什么情绪)"}]__VOLUMES_KEY__}
258
258
  硬性要求:
259
+ - book_title 4-15 字,必须考虑平台书名全站唯一(撞名建不了书):避开「我的XX藏不住」
260
+ 「开局XX」等高频套路组合,书名里带本书独有的专名(人名/金手指/设定词),独有专名越多越不易撞名;
259
261
  - 第 1-3 章是黄金三章:第 1 章前 100 字出现主角、前 200 字出现异常或欲望、前 600 字进入具体困境,
260
262
  第 3 章末留大钩子;不要用世界观或环境描写拖延切入;
261
263
  - 每章都写清“主角主动选择 → 阻碍 → 局面变化 → 章末钩子”,禁止水字数的日常流水账和硬旁白推剧情;
@@ -9,6 +9,7 @@ bundles are never executed by this module.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import hashlib
12
13
  import json
13
14
  import os
14
15
  import re
@@ -523,3 +524,36 @@ def active_mcp_servers():
523
524
  continue
524
525
  return [x for x in out if isinstance(x, dict) and x.get("command")]
525
526
 
527
+
528
+ def merged_servers_text(user_text):
529
+ """用户显式 MCP 清单(JSON 文本)+ 已启用插件的声明 → 合成后的 JSON 文本。
530
+
531
+ 插件服务器名加 ``plg_`` 前缀+短哈希隔离,防与用户配置撞名;用户清单
532
+ 非法或非数组时原样返回,坏插件绝不连累用户显式配置的服务器。
533
+ 逻辑原先长在 main() 启动闭包里、测试够不着(ae9bcb8 半交付时两个
534
+ 用例即因此恒错),2026-09-30 随 /api/plugins 路由补齐一并下沉。"""
535
+ raw = str(user_text or "")
536
+ try:
537
+ configured = json.loads(raw) if raw.strip() else []
538
+ except (ValueError, TypeError):
539
+ return raw
540
+ if not isinstance(configured, list):
541
+ return raw
542
+ try:
543
+ plugin_servers = active_mcp_servers()
544
+ except Exception:
545
+ plugin_servers = []
546
+ for item in plugin_servers or []:
547
+ if not isinstance(item, dict):
548
+ continue
549
+ plugin_id = str(item.pop("plugin_id", "plugin"))
550
+ server_name = str(item.get("name") or "server")
551
+ digest = hashlib.sha256(
552
+ (plugin_id + ":" + server_name).encode("utf-8")).hexdigest()[:6]
553
+ safe = re.sub(r"[^a-z0-9_-]", "-", plugin_id.lower())
554
+ item["name"] = ("plg_" + safe[:11] + "_" + digest)[:24]
555
+ if not any(x.get("name") == item["name"] for x in configured
556
+ if isinstance(x, dict)):
557
+ configured.append(item)
558
+ return json.dumps(configured, ensure_ascii=False)
559
+