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,100 +1,100 @@
1
- # -*- coding: utf-8 -*-
2
- """Run acceptance matrix and evidence normalization.
3
-
4
- The matrix keeps correctness, performance, boundary behavior, exception
5
- handling, readability and security as separate conclusions. Missing evidence
6
- is reported as ``not_evaluated``/``unknown`` rather than being inferred from a
7
- successful process exit.
8
- """
9
- from __future__ import annotations
10
-
11
- import time
12
-
13
- CASES = ("correctness", "performance", "boundary", "exception",
14
- "readability", "security")
15
-
16
-
17
- def _case(name, status, evidence=None, reason=""):
18
- return {"case": name, "status": status,
19
- "evidence": [evidence] if isinstance(evidence, dict) else [],
20
- "reason": str(reason or "")[:300]}
21
-
22
-
23
- def _quality_verdict(run):
24
- verdict = run.get("verdict") or {}
25
- for key in ("pass", "publishable", "verify_pass"):
26
- if isinstance(verdict.get(key), bool):
27
- return verdict.get(key), key
28
- return None, ""
29
-
30
-
31
- def evaluate_run(run, task=None):
32
- """Build a deterministic acceptance result from persisted run evidence."""
33
- run = run or {}
34
- task = task or {}
35
- status = str(run.get("status") or "")
36
- verdict = run.get("verdict") or {}
37
- out = []
38
-
39
- correct, source = _quality_verdict(run)
40
- if status in ("failed", "timeout", "cancelled"):
41
- out.append(_case("correctness", "unknown",
42
- {"source": "run.status", "value": status},
43
- "任务终态未形成可判定的质量结论"))
44
- elif correct is not None:
45
- out.append(_case("correctness", "passed" if correct else "failed",
46
- {"source": "run.verdict", "field": source,
47
- "value": bool(correct)}))
48
- else:
49
- out.append(_case("correctness", "not_evaluated", reason="缺少验证或质量门证据"))
50
-
51
- duration = run.get("duration_s")
52
- if duration is None and run.get("started_at") and run.get("ended_at"):
53
- duration = "recorded"
54
- has_duration = isinstance(duration, (int, float)) and duration >= 0
55
- has_usage = any(isinstance(run.get(key), (int, float)) and run.get(key) > 0
56
- for key in ("tokens", "cost_usd"))
57
- if has_duration or has_usage:
58
- out.append(_case("performance", "passed", {
59
- "source": "run.metrics", "duration_s": duration,
60
- "tokens": run.get("tokens", 0), "cost_usd": run.get("cost_usd", 0),
61
- }, "仅表示性能数据已记录,不代表达到业务阈值"))
62
- else:
63
- out.append(_case("performance", "not_evaluated", reason="缺少耗时或用量证据"))
64
-
65
- boundary = verdict.get("boundary_checks")
66
- if isinstance(boundary, list) and boundary and all(
67
- isinstance(x, dict) and isinstance(x.get("passed"), bool) for x in boundary):
68
- failed = [x for x in boundary if x.get("passed") is False]
69
- out.append(_case("boundary", "failed" if failed else "passed",
70
- {"source": "run.verdict.boundary_checks", "count": len(boundary),
71
- "failed": len(failed)}))
72
- else:
73
- out.append(_case("boundary", "not_evaluated", reason="当前任务未声明边界检查证据"))
74
-
75
- exception = verdict.get("exception")
76
- if isinstance(exception, bool):
77
- out.append(_case("exception", "passed" if exception else "failed",
78
- {"source": "run.verdict.exception", "value": exception}))
79
- elif status in ("failed", "timeout"):
80
- out.append(_case("exception", "failed" if status in ("failed", "timeout") else "unknown",
81
- {"source": "run.error", "status": status,
82
- "error": str(run.get("error") or "")[:300]}))
83
- elif status == "cancelled":
84
- out.append(_case("exception", "unknown",
85
- {"source": "run.status", "value": status}))
86
- else:
87
- out.append(_case("exception", "not_evaluated", reason="缺少独立异常处理证据"))
88
-
89
- for name, key in (("readability", "readability"), ("security", "security")):
90
- value = verdict.get(key)
91
- if isinstance(value, bool):
92
- out.append(_case(name, "passed" if value else "failed",
93
- {"source": "run.verdict.%s" % key, "value": value}))
94
- else:
95
- out.append(_case(name, "not_evaluated", reason="未提供独立 %s 证据" % name))
96
-
97
- # 总体通过要求六个维度都有明确通过证据;缺证据不能被两个局部绿灯掩盖。
98
- return {"version": 1, "evaluated_at": time.time(),
99
- "passed": bool(out and all(x["status"] == "passed" for x in out)),
100
- "cases": out}
1
+ # -*- coding: utf-8 -*-
2
+ """Run acceptance matrix and evidence normalization.
3
+
4
+ The matrix keeps correctness, performance, boundary behavior, exception
5
+ handling, readability and security as separate conclusions. Missing evidence
6
+ is reported as ``not_evaluated``/``unknown`` rather than being inferred from a
7
+ successful process exit.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+
13
+ CASES = ("correctness", "performance", "boundary", "exception",
14
+ "readability", "security")
15
+
16
+
17
+ def _case(name, status, evidence=None, reason=""):
18
+ return {"case": name, "status": status,
19
+ "evidence": [evidence] if isinstance(evidence, dict) else [],
20
+ "reason": str(reason or "")[:300]}
21
+
22
+
23
+ def _quality_verdict(run):
24
+ verdict = run.get("verdict") or {}
25
+ for key in ("pass", "publishable", "verify_pass"):
26
+ if isinstance(verdict.get(key), bool):
27
+ return verdict.get(key), key
28
+ return None, ""
29
+
30
+
31
+ def evaluate_run(run, task=None):
32
+ """Build a deterministic acceptance result from persisted run evidence."""
33
+ run = run or {}
34
+ task = task or {}
35
+ status = str(run.get("status") or "")
36
+ verdict = run.get("verdict") or {}
37
+ out = []
38
+
39
+ correct, source = _quality_verdict(run)
40
+ if status in ("failed", "timeout", "cancelled"):
41
+ out.append(_case("correctness", "unknown",
42
+ {"source": "run.status", "value": status},
43
+ "任务终态未形成可判定的质量结论"))
44
+ elif correct is not None:
45
+ out.append(_case("correctness", "passed" if correct else "failed",
46
+ {"source": "run.verdict", "field": source,
47
+ "value": bool(correct)}))
48
+ else:
49
+ out.append(_case("correctness", "not_evaluated", reason="缺少验证或质量门证据"))
50
+
51
+ duration = run.get("duration_s")
52
+ if duration is None and run.get("started_at") and run.get("ended_at"):
53
+ duration = "recorded"
54
+ has_duration = isinstance(duration, (int, float)) and duration >= 0
55
+ has_usage = any(isinstance(run.get(key), (int, float)) and run.get(key) > 0
56
+ for key in ("tokens", "cost_usd"))
57
+ if has_duration or has_usage:
58
+ out.append(_case("performance", "passed", {
59
+ "source": "run.metrics", "duration_s": duration,
60
+ "tokens": run.get("tokens", 0), "cost_usd": run.get("cost_usd", 0),
61
+ }, "仅表示性能数据已记录,不代表达到业务阈值"))
62
+ else:
63
+ out.append(_case("performance", "not_evaluated", reason="缺少耗时或用量证据"))
64
+
65
+ boundary = verdict.get("boundary_checks")
66
+ if isinstance(boundary, list) and boundary and all(
67
+ isinstance(x, dict) and isinstance(x.get("passed"), bool) for x in boundary):
68
+ failed = [x for x in boundary if x.get("passed") is False]
69
+ out.append(_case("boundary", "failed" if failed else "passed",
70
+ {"source": "run.verdict.boundary_checks", "count": len(boundary),
71
+ "failed": len(failed)}))
72
+ else:
73
+ out.append(_case("boundary", "not_evaluated", reason="当前任务未声明边界检查证据"))
74
+
75
+ exception = verdict.get("exception")
76
+ if isinstance(exception, bool):
77
+ out.append(_case("exception", "passed" if exception else "failed",
78
+ {"source": "run.verdict.exception", "value": exception}))
79
+ elif status in ("failed", "timeout"):
80
+ out.append(_case("exception", "failed" if status in ("failed", "timeout") else "unknown",
81
+ {"source": "run.error", "status": status,
82
+ "error": str(run.get("error") or "")[:300]}))
83
+ elif status == "cancelled":
84
+ out.append(_case("exception", "unknown",
85
+ {"source": "run.status", "value": status}))
86
+ else:
87
+ out.append(_case("exception", "not_evaluated", reason="缺少独立异常处理证据"))
88
+
89
+ for name, key in (("readability", "readability"), ("security", "security")):
90
+ value = verdict.get(key)
91
+ if isinstance(value, bool):
92
+ out.append(_case(name, "passed" if value else "failed",
93
+ {"source": "run.verdict.%s" % key, "value": value}))
94
+ else:
95
+ out.append(_case(name, "not_evaluated", reason="未提供独立 %s 证据" % name))
96
+
97
+ # 总体通过要求六个维度都有明确通过证据;缺证据不能被两个局部绿灯掩盖。
98
+ return {"version": 1, "evaluated_at": time.time(),
99
+ "passed": bool(out and all(x["status"] == "passed" for x in out)),
100
+ "cases": out}
@@ -1,197 +1,197 @@
1
- # -*- coding: utf-8 -*-
2
- """AI 味确定性检测(借鉴 oh-story-claudecode 的去AI味思路)。
3
-
4
- 模型评审是主观分,这里补一道客观参考线:统计译制腔/套话高频词的
5
- 密度,产出报告行注入评审提示词,让评审官把「套话密度」纳入评分
6
- 参考。只做参考不做门禁——「仿佛」「缓缓」在正常小说里也常见,
7
- 误伤风险由评审官(能读懂上下文)兜底,脚本只负责指认位置与密度。
8
- """
9
- from __future__ import annotations
10
-
11
- import re
12
-
13
- # 译制腔/套话词表(按误导性排序,持续运维)
14
- AI_PHRASES = (
15
- "空气仿佛凝固", "眸中闪过一丝", "嘴角勾起一抹", "心中一动",
16
- "值得注意的是", "总而言之", "综上所述", "不难发现",
17
- "在这一刻", "仿佛在诉说着", "无声地诉说着", "见证着",
18
- "深深地看了一眼", "似乎在", "空气仿佛", "一抹微笑",
19
- "不禁", "仿佛",
20
- )
21
-
22
- # 密度告警线(每千字命中次数):超过即提示评审官重点关注
23
- ALERT_PER_KILO = 8.0
24
-
25
- # 叙事架构层信号(借鉴 sepia 2.7k★/StoryScope 研究 2026:AI 小说 93.2% 靠
26
- # 叙事架构特征检出,人工改写措辞后检出率仅从 95.5% 降到 93.9%——措辞层
27
- # 改不掉的架构级指纹才是真破绽)。三类可确定性检测的架构信号:
28
- NARRATIVE_TELLS = {
29
- "顿悟说教": ("终于明白", "这才明白", "明白了,", "意识到,自己", "懂得了",
30
- "原来,成长", "原来,生活", "原来,所谓"),
31
- "情绪身体化": ("心脏猛地", "指尖冰凉", "指尖发凉", "喉咙发紧", "喉头发紧",
32
- "胃里一阵", "胃部一阵", "后背一凉", "血液仿佛", "呼吸一滞"),
33
- "成长式收束": ("释然", "和解", "放下了", "接纳了", "与自己和解", "轻轻松了口气",
34
- "内心归于平静"),
35
- }
36
- # 架构信号告警线比措辞层低:这些表达在好小说里本就该稀缺
37
- NARRATIVE_ALERT_PER_KILO = 2.0
38
- # 「成长式收束」只在结尾才构成架构指纹(中段出现多半是剧情词),只扫尾部
39
- ENDING_SCAN_CHARS = 600
40
-
41
-
42
- def analyze(text):
43
- """统计套话命中。返回 {hits: {短语: 次数}, per_kilo: 每千字密度, alert: bool}。"""
44
- text = text or ""
45
- total = len(text)
46
- hits = {}
47
- for p in AI_PHRASES:
48
- p = p.strip()
49
- if not p:
50
- continue
51
- n = text.count(p)
52
- if n:
53
- hits[p] = n
54
- n_hits = sum(hits.values())
55
- per_kilo = round(n_hits * 1000.0 / total, 2) if total else 0.0
56
- return {"hits": hits, "per_kilo": per_kilo, "alert": per_kilo >= ALERT_PER_KILO}
57
-
58
-
59
- def narrative_analyze(text):
60
- """统计叙事架构层信号。返回 {cats: {类: 次数}, per_kilo, alert, ending_hits}。
61
-
62
- - 前两类全篇统计;「成长式收束」只统计末尾 ENDING_SCAN_CHARS 字
63
- (中段的「和解/放下」是剧情词,结尾的才是成长式收束指纹)。
64
- - per_kilo 为三类合计密度;alert 判据:合计 ≥ 告警线 或 收束类命中 ≥ 2。
65
- """
66
- text = text or ""
67
- total = len(text)
68
- cats, ending_hits = {}, 0
69
- if total:
70
- tail = text[-ENDING_SCAN_CHARS:]
71
- for cat, phrases in NARRATIVE_TELLS.items():
72
- n = 0
73
- for p in phrases:
74
- if cat == "成长式收束":
75
- n += tail.count(p)
76
- else:
77
- n += text.count(p)
78
- if n:
79
- cats[cat] = n
80
- ending_hits = cats.get("成长式收束", 0)
81
- per_kilo = round(sum(cats.values()) * 1000.0 / total, 2) if (total and cats) else 0.0
82
- alert = bool(cats) and (per_kilo >= NARRATIVE_ALERT_PER_KILO or ending_hits >= 2)
83
- return {"cats": cats, "per_kilo": per_kilo, "alert": alert, "ending_hits": ending_hits}
84
-
85
-
86
- # ---------- 节奏/钩子密度(借鉴 web-novel-pacing-analyzer:连载作品的章级
87
- # 节奏审计——读者留存看「每章有没有钩子、段落有没有淤积、是不是通篇独白」,
88
- # 这些是能确定性统计的客观量。与套话检测同路:只给评审官一条参考线,
89
- # 不直接扣分——短段落多的快节奏文也可能开篇没有冲突词。)
90
- NARRATIVE_FLOWS = ("novel", "serial_novel", "video_script")
91
-
92
- # 冲突/悬念信号词表:开篇用来看「抓不抓人」,章末用来看「留不留钩子」
93
- HOOK_WORDS = (
94
- "突然", "忽然", "竟然", "没想到", "万万没想到", "难道", "究竟", "为什么",
95
- "是谁", "怎么回事", "就在这时", "就在此时", "然而", "可是", "却见", "却听",
96
- "不对劲", "不对!", "脸色一变", "猛地", "陡然", "下一秒", "醒来", "睁开眼",
97
- "电话响", "门被推开", "传来", "砸", "吼", "喊",
98
- )
99
- DIALOG_RE = re.compile(r"[「『“\"]")
100
- OPENING_SCAN_CHARS = 300 # 开篇抓力只看前 300 字(黄金三章 / 短视频黄金 3 秒)
101
- PACING_TAIL_CHARS = 200 # 章末钩子只看最后 200 字
102
- PARA_LONG_CHARS = 400 # 单段超过此字数算「信息淤积」
103
- PARA_AVG_ALERT = 300 # 段均长告警线
104
- DIALOG_ALERT_RATIO = 0.12 # 含对话段占比低于此值算「通篇叙述独白」
105
- PACING_MIN_CHARS = 400 # 太短(摘要/片段)不做节奏统计,避免噪声
106
-
107
-
108
- def _paras(text):
109
- """切段:空行或单换行都算分隔;丢掉标题行(# 开头)与空白。"""
110
- out = []
111
- for p in re.split(r"\n\s*\n|\n", text or ""):
112
- p = p.strip()
113
- if p and not p.startswith("#"):
114
- out.append(p)
115
- return out
116
-
117
-
118
- def pacing_analyze(text):
119
- """统计叙事节奏客观量。返回 {ok, paras, avg_len, longest, dialog_ratio,
120
- open_hits, tail_hits, alerts, alert};文本过短时 ok=False。"""
121
- text = text or ""
122
- paras = _paras(text)
123
- if len(text) < PACING_MIN_CHARS or not paras:
124
- return {"ok": False}
125
- lens = [len(p) for p in paras]
126
- avg = round(sum(lens) * 1.0 / len(lens), 1)
127
- longest = max(lens)
128
- dialog = sum(1 for p in paras if DIALOG_RE.search(p))
129
- ratio = round(dialog * 1.0 / len(paras), 3)
130
- opening, tail = text[:OPENING_SCAN_CHARS], text[-PACING_TAIL_CHARS:]
131
- open_hits = sum(1 for w in HOOK_WORDS if w in opening)
132
- tail_hits = sum(1 for w in HOOK_WORDS if w in tail)
133
- alerts = []
134
- if avg >= PARA_AVG_ALERT:
135
- alerts.append("段落偏长(均 %.0f 字),阅读有淤积感" % avg)
136
- if longest >= PARA_LONG_CHARS:
137
- alerts.append("存在超长段(最长 %d 字)" % longest)
138
- if ratio < DIALOG_ALERT_RATIO:
139
- alerts.append("对话段占比偏低(%.0f%%),易成通篇独白" % (ratio * 100))
140
- if not tail_hits:
141
- alerts.append("章末 200 字无悬念信号,缺钩子")
142
- if not open_hits:
143
- alerts.append("开篇 300 字无冲突信号,开头偏平")
144
- return {"ok": True, "paras": len(paras), "avg_len": avg, "longest": longest,
145
- "dialog_ratio": ratio, "open_hits": open_hits, "tail_hits": tail_hits,
146
- "alerts": alerts, "alert": bool(alerts)}
147
-
148
-
149
- def inject_into_prompt(tpl, text, kind=None):
150
- """把确定性检测块插进评审提示词,返回新提示词(无命中原样返回)。
151
-
152
- 位置固定为「## 待评审稿件」之前——与技能包/圣经/知识库注入同一位置,
153
- 保持前缀稳定(§07 T1.2' 前缀缓存)。单稿件评审与连载逐章评审共用本
154
- 函数:此前连载逐章完全没有这条参考线(旗舰场景漏挂)。
155
- """
156
- det = report_line(text, kind)
157
- if not det:
158
- return tpl
159
- return tpl.replace("## 待评审稿件",
160
- "## 确定性检测结果(供评审参考)\n%s\n\n## 待评审稿件" % det, 1)
161
-
162
-
163
- def report_line(text, kind=None):
164
- """生成注入评审提示词的报告行(措辞层 + 叙事架构层 + 节奏层);
165
- 全部无命中返回空串。kind 为流程类型:只有叙事类流程才下发节奏检测
166
- (邮件/汇报/翻译等非叙事文体统计段落节奏没有意义)。"""
167
- lines = []
168
- r = analyze(text)
169
- if r["hits"]:
170
- top = "、".join("「%s」×%d" % (k, v)
171
- for k, v in sorted(r["hits"].items(), key=lambda x: -x[1])[:8])
172
- line = "- [AI味检测] 确定性统计:套话密度 %.1f/千字(%s)。" % (r["per_kilo"], top)
173
- if r["alert"]:
174
- line += "密度超过告警线 %.0f/千字,请重点评审译制腔与套话问题。" % ALERT_PER_KILO
175
- lines.append(line)
176
- nr = narrative_analyze(text)
177
- if nr["cats"]:
178
- top = "、".join("「%s」×%d" % (k, v) for k, v in nr["cats"].items())
179
- line = ("- [叙事架构信号] 确定性统计(措辞改写不掉的架构级指纹):"
180
- "%s,合计 %.1f/千字。" % (top, nr["per_kilo"]))
181
- if nr["alert"]:
182
- line += ("出现架构级 AI 指纹(顿悟说教/情绪只写身体反应/成长式收束),"
183
- "请评审情节结构:主题是否被叙述者直接说破、情绪是否只有身体描写、"
184
- "结尾是否靠主角想通收束。")
185
- lines.append(line)
186
- if kind in NARRATIVE_FLOWS:
187
- pr = pacing_analyze(text)
188
- if pr["ok"]:
189
- line = ("- [节奏检测] 确定性统计:%d 段(均 %.0f 字 / 最长 %d 字),"
190
- "含对话段 %.0f%%,开篇 300 字冲突信号 %d 处,章末 200 字悬念信号 %d 处。"
191
- % (pr["paras"], pr["avg_len"], pr["longest"],
192
- pr["dialog_ratio"] * 100, pr["open_hits"], pr["tail_hits"]))
193
- if pr["alert"]:
194
- line += ("提示:%s。连载留存取决于每章的钩子与段落节奏,"
195
- "请评审官结合剧情判断是否真问题。" % "、".join(pr["alerts"]))
196
- lines.append(line)
197
- return "\n".join(lines)
1
+ # -*- coding: utf-8 -*-
2
+ """AI 味确定性检测(借鉴 oh-story-claudecode 的去AI味思路)。
3
+
4
+ 模型评审是主观分,这里补一道客观参考线:统计译制腔/套话高频词的
5
+ 密度,产出报告行注入评审提示词,让评审官把「套话密度」纳入评分
6
+ 参考。只做参考不做门禁——「仿佛」「缓缓」在正常小说里也常见,
7
+ 误伤风险由评审官(能读懂上下文)兜底,脚本只负责指认位置与密度。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ # 译制腔/套话词表(按误导性排序,持续运维)
14
+ AI_PHRASES = (
15
+ "空气仿佛凝固", "眸中闪过一丝", "嘴角勾起一抹", "心中一动",
16
+ "值得注意的是", "总而言之", "综上所述", "不难发现",
17
+ "在这一刻", "仿佛在诉说着", "无声地诉说着", "见证着",
18
+ "深深地看了一眼", "似乎在", "空气仿佛", "一抹微笑",
19
+ "不禁", "仿佛",
20
+ )
21
+
22
+ # 密度告警线(每千字命中次数):超过即提示评审官重点关注
23
+ ALERT_PER_KILO = 8.0
24
+
25
+ # 叙事架构层信号(借鉴 sepia 2.7k★/StoryScope 研究 2026:AI 小说 93.2% 靠
26
+ # 叙事架构特征检出,人工改写措辞后检出率仅从 95.5% 降到 93.9%——措辞层
27
+ # 改不掉的架构级指纹才是真破绽)。三类可确定性检测的架构信号:
28
+ NARRATIVE_TELLS = {
29
+ "顿悟说教": ("终于明白", "这才明白", "明白了,", "意识到,自己", "懂得了",
30
+ "原来,成长", "原来,生活", "原来,所谓"),
31
+ "情绪身体化": ("心脏猛地", "指尖冰凉", "指尖发凉", "喉咙发紧", "喉头发紧",
32
+ "胃里一阵", "胃部一阵", "后背一凉", "血液仿佛", "呼吸一滞"),
33
+ "成长式收束": ("释然", "和解", "放下了", "接纳了", "与自己和解", "轻轻松了口气",
34
+ "内心归于平静"),
35
+ }
36
+ # 架构信号告警线比措辞层低:这些表达在好小说里本就该稀缺
37
+ NARRATIVE_ALERT_PER_KILO = 2.0
38
+ # 「成长式收束」只在结尾才构成架构指纹(中段出现多半是剧情词),只扫尾部
39
+ ENDING_SCAN_CHARS = 600
40
+
41
+
42
+ def analyze(text):
43
+ """统计套话命中。返回 {hits: {短语: 次数}, per_kilo: 每千字密度, alert: bool}。"""
44
+ text = text or ""
45
+ total = len(text)
46
+ hits = {}
47
+ for p in AI_PHRASES:
48
+ p = p.strip()
49
+ if not p:
50
+ continue
51
+ n = text.count(p)
52
+ if n:
53
+ hits[p] = n
54
+ n_hits = sum(hits.values())
55
+ per_kilo = round(n_hits * 1000.0 / total, 2) if total else 0.0
56
+ return {"hits": hits, "per_kilo": per_kilo, "alert": per_kilo >= ALERT_PER_KILO}
57
+
58
+
59
+ def narrative_analyze(text):
60
+ """统计叙事架构层信号。返回 {cats: {类: 次数}, per_kilo, alert, ending_hits}。
61
+
62
+ - 前两类全篇统计;「成长式收束」只统计末尾 ENDING_SCAN_CHARS 字
63
+ (中段的「和解/放下」是剧情词,结尾的才是成长式收束指纹)。
64
+ - per_kilo 为三类合计密度;alert 判据:合计 ≥ 告警线 或 收束类命中 ≥ 2。
65
+ """
66
+ text = text or ""
67
+ total = len(text)
68
+ cats, ending_hits = {}, 0
69
+ if total:
70
+ tail = text[-ENDING_SCAN_CHARS:]
71
+ for cat, phrases in NARRATIVE_TELLS.items():
72
+ n = 0
73
+ for p in phrases:
74
+ if cat == "成长式收束":
75
+ n += tail.count(p)
76
+ else:
77
+ n += text.count(p)
78
+ if n:
79
+ cats[cat] = n
80
+ ending_hits = cats.get("成长式收束", 0)
81
+ per_kilo = round(sum(cats.values()) * 1000.0 / total, 2) if (total and cats) else 0.0
82
+ alert = bool(cats) and (per_kilo >= NARRATIVE_ALERT_PER_KILO or ending_hits >= 2)
83
+ return {"cats": cats, "per_kilo": per_kilo, "alert": alert, "ending_hits": ending_hits}
84
+
85
+
86
+ # ---------- 节奏/钩子密度(借鉴 web-novel-pacing-analyzer:连载作品的章级
87
+ # 节奏审计——读者留存看「每章有没有钩子、段落有没有淤积、是不是通篇独白」,
88
+ # 这些是能确定性统计的客观量。与套话检测同路:只给评审官一条参考线,
89
+ # 不直接扣分——短段落多的快节奏文也可能开篇没有冲突词。)
90
+ NARRATIVE_FLOWS = ("novel", "serial_novel", "video_script")
91
+
92
+ # 冲突/悬念信号词表:开篇用来看「抓不抓人」,章末用来看「留不留钩子」
93
+ HOOK_WORDS = (
94
+ "突然", "忽然", "竟然", "没想到", "万万没想到", "难道", "究竟", "为什么",
95
+ "是谁", "怎么回事", "就在这时", "就在此时", "然而", "可是", "却见", "却听",
96
+ "不对劲", "不对!", "脸色一变", "猛地", "陡然", "下一秒", "醒来", "睁开眼",
97
+ "电话响", "门被推开", "传来", "砸", "吼", "喊",
98
+ )
99
+ DIALOG_RE = re.compile(r"[「『“\"]")
100
+ OPENING_SCAN_CHARS = 300 # 开篇抓力只看前 300 字(黄金三章 / 短视频黄金 3 秒)
101
+ PACING_TAIL_CHARS = 200 # 章末钩子只看最后 200 字
102
+ PARA_LONG_CHARS = 400 # 单段超过此字数算「信息淤积」
103
+ PARA_AVG_ALERT = 300 # 段均长告警线
104
+ DIALOG_ALERT_RATIO = 0.12 # 含对话段占比低于此值算「通篇叙述独白」
105
+ PACING_MIN_CHARS = 400 # 太短(摘要/片段)不做节奏统计,避免噪声
106
+
107
+
108
+ def _paras(text):
109
+ """切段:空行或单换行都算分隔;丢掉标题行(# 开头)与空白。"""
110
+ out = []
111
+ for p in re.split(r"\n\s*\n|\n", text or ""):
112
+ p = p.strip()
113
+ if p and not p.startswith("#"):
114
+ out.append(p)
115
+ return out
116
+
117
+
118
+ def pacing_analyze(text):
119
+ """统计叙事节奏客观量。返回 {ok, paras, avg_len, longest, dialog_ratio,
120
+ open_hits, tail_hits, alerts, alert};文本过短时 ok=False。"""
121
+ text = text or ""
122
+ paras = _paras(text)
123
+ if len(text) < PACING_MIN_CHARS or not paras:
124
+ return {"ok": False}
125
+ lens = [len(p) for p in paras]
126
+ avg = round(sum(lens) * 1.0 / len(lens), 1)
127
+ longest = max(lens)
128
+ dialog = sum(1 for p in paras if DIALOG_RE.search(p))
129
+ ratio = round(dialog * 1.0 / len(paras), 3)
130
+ opening, tail = text[:OPENING_SCAN_CHARS], text[-PACING_TAIL_CHARS:]
131
+ open_hits = sum(1 for w in HOOK_WORDS if w in opening)
132
+ tail_hits = sum(1 for w in HOOK_WORDS if w in tail)
133
+ alerts = []
134
+ if avg >= PARA_AVG_ALERT:
135
+ alerts.append("段落偏长(均 %.0f 字),阅读有淤积感" % avg)
136
+ if longest >= PARA_LONG_CHARS:
137
+ alerts.append("存在超长段(最长 %d 字)" % longest)
138
+ if ratio < DIALOG_ALERT_RATIO:
139
+ alerts.append("对话段占比偏低(%.0f%%),易成通篇独白" % (ratio * 100))
140
+ if not tail_hits:
141
+ alerts.append("章末 200 字无悬念信号,缺钩子")
142
+ if not open_hits:
143
+ alerts.append("开篇 300 字无冲突信号,开头偏平")
144
+ return {"ok": True, "paras": len(paras), "avg_len": avg, "longest": longest,
145
+ "dialog_ratio": ratio, "open_hits": open_hits, "tail_hits": tail_hits,
146
+ "alerts": alerts, "alert": bool(alerts)}
147
+
148
+
149
+ def inject_into_prompt(tpl, text, kind=None):
150
+ """把确定性检测块插进评审提示词,返回新提示词(无命中原样返回)。
151
+
152
+ 位置固定为「## 待评审稿件」之前——与技能包/圣经/知识库注入同一位置,
153
+ 保持前缀稳定(§07 T1.2' 前缀缓存)。单稿件评审与连载逐章评审共用本
154
+ 函数:此前连载逐章完全没有这条参考线(旗舰场景漏挂)。
155
+ """
156
+ det = report_line(text, kind)
157
+ if not det:
158
+ return tpl
159
+ return tpl.replace("## 待评审稿件",
160
+ "## 确定性检测结果(供评审参考)\n%s\n\n## 待评审稿件" % det, 1)
161
+
162
+
163
+ def report_line(text, kind=None):
164
+ """生成注入评审提示词的报告行(措辞层 + 叙事架构层 + 节奏层);
165
+ 全部无命中返回空串。kind 为流程类型:只有叙事类流程才下发节奏检测
166
+ (邮件/汇报/翻译等非叙事文体统计段落节奏没有意义)。"""
167
+ lines = []
168
+ r = analyze(text)
169
+ if r["hits"]:
170
+ top = "、".join("「%s」×%d" % (k, v)
171
+ for k, v in sorted(r["hits"].items(), key=lambda x: -x[1])[:8])
172
+ line = "- [AI味检测] 确定性统计:套话密度 %.1f/千字(%s)。" % (r["per_kilo"], top)
173
+ if r["alert"]:
174
+ line += "密度超过告警线 %.0f/千字,请重点评审译制腔与套话问题。" % ALERT_PER_KILO
175
+ lines.append(line)
176
+ nr = narrative_analyze(text)
177
+ if nr["cats"]:
178
+ top = "、".join("「%s」×%d" % (k, v) for k, v in nr["cats"].items())
179
+ line = ("- [叙事架构信号] 确定性统计(措辞改写不掉的架构级指纹):"
180
+ "%s,合计 %.1f/千字。" % (top, nr["per_kilo"]))
181
+ if nr["alert"]:
182
+ line += ("出现架构级 AI 指纹(顿悟说教/情绪只写身体反应/成长式收束),"
183
+ "请评审情节结构:主题是否被叙述者直接说破、情绪是否只有身体描写、"
184
+ "结尾是否靠主角想通收束。")
185
+ lines.append(line)
186
+ if kind in NARRATIVE_FLOWS:
187
+ pr = pacing_analyze(text)
188
+ if pr["ok"]:
189
+ line = ("- [节奏检测] 确定性统计:%d 段(均 %.0f 字 / 最长 %d 字),"
190
+ "含对话段 %.0f%%,开篇 300 字冲突信号 %d 处,章末 200 字悬念信号 %d 处。"
191
+ % (pr["paras"], pr["avg_len"], pr["longest"],
192
+ pr["dialog_ratio"] * 100, pr["open_hits"], pr["tail_hits"]))
193
+ if pr["alert"]:
194
+ line += ("提示:%s。连载留存取决于每章的钩子与段落节奏,"
195
+ "请评审官结合剧情判断是否真问题。" % "、".join(pr["alerts"]))
196
+ lines.append(line)
197
+ return "\n".join(lines)