dsh-distillation-director 4.6.5

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.
@@ -0,0 +1,795 @@
1
+ # -*- coding: utf-8 -*-
2
+ """9 维机器初评器 v2(试点 A 优化轮 · 零 LLM 本地 py)
3
+
4
+ v2 = v1 + (1) d6 池 id 实存自动核验(家族约定文件自动发现)
5
+ (2) test-prompts.json 盘点(供 d8 judge 快速定位)
6
+ (3) runtime 红灯扫描(darwin gate 项)
7
+ (4) 验证面扩展:对比器 v2 跑 6 技能(rse/trv/afd/oc/lvs/rp)
8
+
9
+ v4.2(2026-09-12):加 `--host-only-refs`/`--book-base` 两个 opt-in 开关(默认行为不变)。
10
+ v4.3(2026-09-12,**当前默认口径已改**):把 v4.2 的"正确口径"合入默认,并加两个复现开关——
11
+ · **d6-c1**:技能路径在 `.dsh\skills\` 下(宿主技能)时,实存集合**只取宿主一级目录**(原稿副本不算"实存",
12
+ 因为引擎不加载原稿树)。复现旧口径:加 `--drafts-in-refs`。
13
+ · **d6-c2/c3/c5**:宿主技能默认**自动定位原书册根**(按 slug 在 `投资蒸馏/**/skills/<slug>/` 反查,排除 snapshots);
14
+ 查不到(如 buffett/munger 这类宿主原创技能)则发 `skip`(deduct 0,不再恒 fail)。复现旧口径:加 `--legacy-book-checks`。
15
+ · 理由:旧口径下这三条对**全部宿主技能**恒 fail,是**结构性假阳性**,会把"零新增 fail"的批界对比污染成"假性新增";
16
+ 同时 d6-c1 会漏判归档死链。改对之后只有**真实**问题才报 fail。
17
+
18
+ **权威位置**:本文件是权威实现,与 `distillation-director-plugin\scripts\machine_precheck_v2.py` 保持**逐字节同步**;
19
+ 一致性检查脚本 `check_script_sync.py`(本目录)。改这里之后请同步插件副本。
20
+
21
+ 对一份 SKILL.md 跑 9 维"机械层初评":
22
+ - 只做可确定性判定的子规则(rubric 明文规则 + 枚举式关键词/行号证据);
23
+ - 语义窄域("步骤真可执行""描述含金量""冗余程度"等)不硬判,
24
+ 以 semantic_only_notes 显式交给 LLM judge 复核;
25
+ - 输出 machine-report-<slug>.json:逐维 {coverage, checks[{rule,status,evidence,deduct}],
26
+ machine_deduct, machine_raw, semantic_only_notes}。
27
+
28
+ 幅度基线(v1 未校准,只做"方向+候选"级初评):
29
+ - 扣分触发项默认各 -1;rubric 明示幅度的按 rubric(d5 软化词>=3 -> -3;d9 无反例章 -> -3+)。
30
+ - 每条 machine_raw = 10 - machine_deduct(仅当该维 coverage != none 时给出),供与
31
+ judge raw 对拍看"方向一致性与幅度漂移",不冒充校准后分数。
32
+
33
+ 用法:
34
+ python machine_precheck_v2.py <SKILL.md 路径> [--out <json 路径>]
35
+ [--book-base <该书册根目录>] [--drafts-in-refs] [--legacy-book-checks]
36
+ """
37
+ import os
38
+ import re
39
+ import sys
40
+ import json
41
+ import glob as _g
42
+
43
+ # --- UTF-8 输出保护(甲-A5 严格版 · 2026-09-13 加入;坑 P-16)---
44
+ # 本块由 utf8_guard_patch.py 幂等插入,勿手删:GBK 控制台下打印 ✔ 会抛
45
+ # UnicodeEncodeError → 退出码非 0 的**假报警**(前面所有闸其实都过了)。
46
+ if os.environ.get('PYTHONIOENCODING', '').lower() != 'utf-8':
47
+ os.environ['PYTHONIOENCODING'] = 'utf-8'
48
+ try:
49
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
50
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
51
+ except Exception:
52
+ pass
53
+ # --- UTF-8 输出保护 结束 ---
54
+
55
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # 蒸馏工作区
56
+
57
+ # ------------------- v4.3 默认口径(由 v4.2 的 opt-in 提升为默认;可显式回退) -------------------
58
+ # 背景(2026-09-12 实测):
59
+ # ① d6-c1 的"全库实存"把**原稿副本**(投资蒸馏/**/skills/*/SKILL.md)也算作实存 →
60
+ # 归档 slug 只要还有原稿副本就不被判缺 → 该检查**测不出归档死链**。
61
+ # → v4.3 默认:宿主技能只用**宿主技能根一级目录**做实存集合;`--drafts-in-refs` 复现旧口径。
62
+ # ② d6-c2/c3/c5 用 skill_path 反推书册根目录(取路径里 'skills' 段的父目录)→ 对**宿主技能**
63
+ # (…\金融投资\.dsh\skills\<slug>\SKILL.md)反推出 …\金融投资\.dsh,那里永无 candidates/ →
64
+ # 这三条对全部宿主技能恒 fail/warn。
65
+ # → v4.3 默认:宿主技能先按 slug 自动反查原书册根;查不到则三条发 skip(不扣分);
66
+ # `--legacy-book-checks` 复现旧的"反推 + 恒 fail"行为。
67
+ HOST_ONLY_REFS = True # v4.3 默认开启;--drafts-in-refs 关闭
68
+ LEGACY_BOOK_CHECKS = False # v4.3 默认关闭;--legacy-book-checks 打开
69
+ BOOK_BASE_OVERRIDE = None
70
+
71
+
72
+ def _is_host_skill(sp):
73
+ """技能路径是否位于某工作区的 `.dsh/skills/` 下(= 引擎真正加载的那类技能)。"""
74
+ parts = os.path.abspath(sp).split(os.sep)
75
+ return '.dsh' in parts and 'skills' in parts and parts.index('.dsh') < parts.index('skills')
76
+
77
+
78
+ def _auto_book_base(slug):
79
+ """按 slug 在蒸馏树里反查原书册根(排除 snapshots/备份),找不到返回 None。"""
80
+ hits = []
81
+ for cand in _g.glob(os.path.join(BASE_DIR, '投资蒸馏', '**', 'skills', slug, 'SKILL.md'), recursive=True):
82
+ if 'snapshots' in cand or '备份' in cand:
83
+ continue
84
+ hits.append(cand)
85
+ if not hits:
86
+ return None
87
+ hits.sort(key=len)
88
+ return os.path.dirname(os.path.dirname(os.path.dirname(hits[0])))
89
+
90
+ def _host_skills_root(sp):
91
+ r"""v4.4(2026-09-13):从被检文件路径反推它所属的**宿主技能根**(取代硬编码金融宿主)。
92
+
93
+ 形状 `…\<宿主>\.dsh\skills\<slug>\SKILL.md` → 返回 `…\<宿主>\.dsh\skills`;
94
+ 形状不符(非宿主技能)→ 返回 None,调用方回落到旧常量,保持向后兼容。
95
+ 修因:旧版把"宿主实存集合"写死成金融投资宿主,对教育线 22 件造成 16 件 `d6-c1` **假扣分**。
96
+ """
97
+ parts = os.path.abspath(sp).split(os.sep)
98
+ for i in range(len(parts) - 1, 0, -1):
99
+ if parts[i] == 'skills' and parts[i - 1] == '.dsh':
100
+ return os.sep.join(parts[:i + 1])
101
+ return None
102
+
103
+ # ----------------------------- 工具 -----------------------------
104
+
105
+ def read_lines(path):
106
+ with open(path, encoding="utf-8") as f:
107
+ text = f.read()
108
+ return text, text.splitlines()
109
+
110
+ def frontmatter_meta(lines):
111
+ """解析 --- ... --- frontmatter,返回 {key: value} 与 description 原文。"""
112
+ meta = {}
113
+ if not lines or lines[0].strip() != "---":
114
+ return meta, ""
115
+ end = None
116
+ for i in range(1, len(lines)):
117
+ if lines[i].strip() == "---":
118
+ end = i
119
+ break
120
+ if end is None:
121
+ return meta, ""
122
+ key = None
123
+ for ln in lines[1:end]:
124
+ m = re.match(r"^([A-Za-z_]+):\s*(.*)$", ln, re.S)
125
+ if m:
126
+ if key:
127
+ meta[key] = meta.get(key, "").rstrip()
128
+ key, val = m.group(1), m.group(2)
129
+ meta[key] = val
130
+ elif key and ln.startswith((" ", "\t")):
131
+ meta[key] = meta.get(key, "") + " " + ln.strip()
132
+ if key:
133
+ meta[key] = meta.get(key, "").rstrip()
134
+ desc = meta.get("description", "").strip()
135
+ if desc.startswith('"') and desc.endswith('"') and len(desc) >= 2:
136
+ desc = desc[1:-1]
137
+ return meta, desc
138
+
139
+ def find_section(lines, head_re):
140
+ """返回 0-based [start,end) 行区间;找不到返回 None。"""
141
+ start = None
142
+ for i, ln in enumerate(lines):
143
+ if re.match(head_re, ln):
144
+ start = i
145
+ break
146
+ if start is None:
147
+ return None
148
+ end = len(lines)
149
+ for j in range(start + 1, len(lines)):
150
+ if re.match(r"^##\s+", lines[j]):
151
+ end = j
152
+ break
153
+ return start, end
154
+
155
+ def hits(lines, start, end, pattern, flag=re.I):
156
+ rx = re.compile(pattern, flag)
157
+ out = []
158
+ for i in range(start, end):
159
+ ln = lines[i]
160
+ if rx.search(ln):
161
+ t = ln.strip()
162
+ if len(t) > 110:
163
+ t = t[:107] + "..."
164
+ out.append({"line": i + 1, "text": t})
165
+ return out
166
+
167
+ def count_in(lines, start, end, pattern, flag=re.I):
168
+ rx = re.compile(pattern, flag)
169
+ return sum(1 for i in range(start, end) if rx.search(lines[i]))
170
+
171
+ # ----------------------------- 词表 -----------------------------
172
+
173
+ AI_BANNED = ["说白了", "换句话说", "综上所述", "总而言之", "值得注意的是", "需要注意的是", "不难发现", "显而易见", "总的来说"]
174
+ SOFT_HARD = ["可以考虑", "根据情况", "灵活把握", "视情况而定", "灵活应用", "视条件而定"]
175
+ EMPTY_TAIL = ["灵活应用", "根据情况判断", "视情况而定", "灵活把握"]
176
+ POOL_FILE_RE = re.compile(r"candidates/[A-Za-z0-9_\-./]+\.md")
177
+ POOL_ID_RE = re.compile(r"(?<![A-Za-z0-9])(ce|pr|fw|ca|gl)(\d{2})")
178
+ ECHO_PHRASES = ["转专业帮助", "trader-discipline", "optionality-convexity", "fund-selector", "investing-mindset"]
179
+ FAM_FILE = {"ce": "counter-examples.md", "pr": "principles.md", "fw": "frameworks.md", "ca": "cases.md", "gl": "glossary.md"}
180
+ RT_TERMS = ["在 Claude Code", "Claude Code skill", "Claude Code 用户", "Cursor only", "Codex 中", r"~\\.claude/skills/", r"/plugin install\\b"]
181
+
182
+ RT_RX = re.compile("在 Claude Code|Claude Code skill|Claude Code 用户|Cursor only|Codex 中|~/" + chr(92) + ".claude/skills/|/plugin install" + chr(92) + "b")
183
+
184
+ # ----------------------------- 各维检查 -----------------------------
185
+
186
+ def check_d1(lines, meta, desc):
187
+ checks, deduct = [], 0
188
+ name = meta.get("name", "")
189
+ if not name:
190
+ checks.append({"id": "d1-c1", "rule": "name 存在且小写 kebab 规范", "status": "fail",
191
+ "evidence": [], "deduct": 2, "note": "frontmatter 缺 name"})
192
+ deduct += 2
193
+ else:
194
+ ok = bool(re.fullmatch(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*", name))
195
+ checks.append({"id": "d1-c1", "rule": "name 小写 kebab 规范", "status": "pass" if ok else "fail",
196
+ "evidence": [{"line": 2, "text": "name: " + name}], "deduct": 0 if ok else 2,
197
+ "note": "" if ok else "name 建议小写连字符"})
198
+ if not ok:
199
+ deduct += 2
200
+ n = len(desc)
201
+ ev_len = [{"line": 3, "text": "description...(%d 字符)" % n}] if desc else []
202
+ if n == 0:
203
+ checks.append({"id": "d1-c2", "rule": "description 存在", "status": "fail", "evidence": ev_len,
204
+ "deduct": 2, "note": "缺 description"})
205
+ deduct += 2
206
+ elif n > 1024:
207
+ checks.append({"id": "d1-c2", "rule": "description 字符数 <=1024(rubric 上限)", "status": "fail",
208
+ "evidence": [{"line": 3, "text": "desc_len=%d > 1024" % n}], "deduct": 1,
209
+ "note": "与 judge 扣分同源;幅度未校准(judge 示例曾按超限计 -1~-2)"})
210
+ deduct += 1
211
+ else:
212
+ checks.append({"id": "d1-c2", "rule": "description 字符数 <=1024", "status": "pass",
213
+ "evidence": [{"line": 3, "text": "desc_len=%d <= 1024" % n}], "deduct": 0, "note": ""})
214
+ # d1-c3 标记词判据(口径修正 2026-09-13 · 同族坑 A-04「判据词表漏一种写法=整族假警报」):
215
+ # 旧写法只认字面子串 "何时用",而本线 desc 写的是 **「何时不用」**(让位段)——「何时不用」不含子串「何时用」,
216
+ # 于是两件技能恒报 missing=何时用(独立判官判为**工装假阳**:让位段正是完成"可路由性"的声明)。
217
+ # 新口径=「何时用」**家族**(何时用/何时不用/让位/缺省优先 任一命中即算标记段在);
218
+ # **「触发词」仍为独立必需项**,不得用家族扩项替代。
219
+ MARK_USAGE = ["何时用", "何时不用", "让位", "缺省优先"]
220
+ missing = ([] if any(k in desc for k in MARK_USAGE) else ["何时用(家族)"])
221
+ if "触发词" not in desc:
222
+ missing.append("触发词")
223
+ if missing:
224
+ checks.append({"id": "d1-c3", "rule": "description 含标记词:何时用(家族)/触发词", "status": "warn",
225
+ "evidence": [{"line": 3, "text": "missing=" + ",".join(missing)}], "deduct": 0,
226
+ "note": "标记词缺失=语义存疑交 judge;机器不判'做什么'含金量"})
227
+ else:
228
+ checks.append({"id": "d1-c3", "rule": "description 含标记词:何时用(家族)/触发词", "status": "pass",
229
+ "evidence": [{"line": 3, "text": "含 何时用(家族)/触发词 标记"}], "deduct": 0, "note": ""})
230
+ tail = desc[-30:] if desc else ""
231
+ bad_tail = [w for w in EMPTY_TAIL if w in tail]
232
+ if bad_tail:
233
+ checks.append({"id": "d1-c4", "rule": "无'灵活应用/根据情况判断'式空话尾巴", "status": "fail",
234
+ "evidence": [{"line": 3, "text": "tail_hit=" + ",".join(bad_tail)}], "deduct": 2,
235
+ "note": "rubric d1 明示禁止"})
236
+ deduct += 2
237
+ else:
238
+ checks.append({"id": "d1-c4", "rule": "无'灵活应用/根据情况判断'式空话尾巴", "status": "pass",
239
+ "evidence": [], "deduct": 0, "note": ""})
240
+ semantic = ["description 是否真含'做什么'、内容与正文是否语义重复(机器不看,交 judge)"]
241
+ return checks, min(deduct, 8), semantic
242
+
243
+ def check_d2(lines):
244
+ seg = find_section(lines, r"^##\s*E")
245
+ if seg is None:
246
+ return [{"id": "d2-c1", "rule": "E 可执行步骤节存在", "status": "fail", "evidence": [],
247
+ "deduct": 3, "note": "找不到 ## E 节"}], 3, ["无 E 节"]
248
+ start, end = seg
249
+ ev_steps = hits(lines, start, end, r"步骤\s*[0-90-9]")
250
+ steps = set()
251
+ tr = str.maketrans("0123456789", "0123456789")
252
+ for h in ev_steps:
253
+ m = re.search(r"步骤\s*([0-90-9]+)", h["text"])
254
+ if m:
255
+ steps.add(int(m.group(1).translate(tr)))
256
+ n_in = count_in(lines, start, end, r"输入")
257
+ n_out = count_in(lines, start, end, r"输出")
258
+ n_exit = count_in(lines, start, end, r"出口")
259
+ n_act = count_in(lines, start, end, r"动作")
260
+ checks, deduct = [], 0
261
+ if len(steps) < 5:
262
+ checks.append({"id": "d2-c1", "rule": "E 内有序号步骤 >=5 个", "status": "fail",
263
+ "evidence": ev_steps[:8], "deduct": 2, "note": "实际步骤数=%d" % len(steps)})
264
+ deduct += 2
265
+ else:
266
+ checks.append({"id": "d2-c1", "rule": "E 内有序号步骤 >=5 个", "status": "pass",
267
+ "evidence": [{"line": ev_steps[0]["line"], "text": "found %d 个步骤" % len(steps)}],
268
+ "deduct": 0, "note": ""})
269
+ io = {"输入": n_in, "动作": n_act, "输出": n_out, "出口": n_exit}
270
+ if min(n_out, n_in, n_act) < 3 or n_exit < 2:
271
+ checks.append({"id": "d2-c2", "rule": "每步有 输入/动作/输出/出口 字段规格", "status": "warn",
272
+ "evidence": [{"line": start + 1, "text": "io_counts=" + json.dumps(io, ensure_ascii=False)}],
273
+ "deduct": 0, "note": "字段标记不足;机器只数词频,交 judge 判颗粒度"})
274
+ else:
275
+ checks.append({"id": "d2-c2", "rule": "每步有 输入/动作/输出/出口 字段规格", "status": "pass",
276
+ "evidence": [{"line": start + 1, "text": "io_counts=" + json.dumps(io, ensure_ascii=False)}],
277
+ "deduct": 0, "note": ""})
278
+ # d2-c3 逐步骤字段矩阵(v3.1 新增:只给 judge 语义证据,不扣分——捕捉步骤0 类缺输出字段的颗粒度问题)
279
+ step_rows = []
280
+ cur_step = None
281
+ cur_txt = []
282
+ def flush_step():
283
+ nonlocal cur_step, cur_txt
284
+ if cur_step is not None:
285
+ txt = " ".join(cur_txt)
286
+ step_rows.append((cur_step, txt))
287
+ cur_step = None
288
+ cur_txt = []
289
+ for i in range(start, end):
290
+ m = re.match(r"^[-*]?\s*\*\*步骤\s*([0-90-9]+)", lines[i])
291
+ if m:
292
+ flush_step()
293
+ cur_step = int(m.group(1).translate(str.maketrans("0123456789", "0123456789")))
294
+ cur_txt = [lines[i]]
295
+ elif cur_step is not None:
296
+ cur_txt.append(lines[i])
297
+ flush_step()
298
+ # v0.2 修正(M5):d2-c2 原只数 输入/动作/输出/出口 的词频,会把正文里出现的同名词也算进去。
299
+ # 现按**步骤块**统计字段覆盖,并回填证据;仅在覆盖不全时把该检查升级为 warn(不改变 pass→fail 语义)。
300
+ cov = {f: sum(1 for _sn, _t in step_rows if f in _t) for f in ("输入", "动作", "输出", "出口")}
301
+ n_steps = len(step_rows)
302
+ for _c in checks:
303
+ if _c["id"] == "d2-c2":
304
+ _c["evidence"] = [{"line": start + 1,
305
+ "text": "per_step_field_coverage=%s of %d steps; io_word_counts=%s"
306
+ % (json.dumps(cov, ensure_ascii=False), n_steps, json.dumps(io, ensure_ascii=False))}]
307
+ _miss_field = [f for f, n in cov.items() if n < n_steps]
308
+ if _miss_field and _c["status"] == "pass":
309
+ _c["status"] = "warn"
310
+ _c["note"] = "字段覆盖不全(缺 %s 的步骤存在);机器只报矩阵,judge 判颗粒度" % "/".join(_miss_field)
311
+ else:
312
+ _c["note"] = (_c.get("note") or "") + "(v0.2:按步骤块统计,见 per_step_field_coverage)"
313
+ miss_steps = []
314
+ for sn, txt in step_rows:
315
+ if ("输出" not in txt) or ("出口" not in txt):
316
+ miss_steps.append(sn)
317
+ if miss_steps:
318
+ checks.append({"id": "d2-c3", "rule": "逐步骤 输入/输出/出口 字段齐全(矩阵)", "status": "warn",
319
+ "evidence": [{"line": start + 1, "text": "steps_missing_output_or_exit=" + ",".join(map(str, miss_steps))}],
320
+ "deduct": 0,
321
+ "note": "v3.1 机器证据:缺失输出/出口的步骤清单(judge 语义裁决是否扣分,如 rse 步骤0 先例)"})
322
+ else:
323
+ checks.append({"id": "d2-c3", "rule": "逐步骤 输入/输出/出口 字段齐全(矩阵)", "status": "pass",
324
+ "evidence": [], "deduct": 0, "note": "全部步骤含输出与出口字段"})
325
+ semantic = ["步骤是否真可执行/输入输出字段颗粒度是否一致(rse judge 曾因步骤0 缺'输出'扣1;机器只给 d2-c3 矩阵证据,不扣分)"]
326
+ return checks, deduct, semantic
327
+
328
+ def check_d3(lines):
329
+ seg = find_section(lines, r"^##\s*E")
330
+ if seg is None:
331
+ return [{"id": "d3-c1", "rule": "失败模式编码节存在", "status": "fail", "evidence": [],
332
+ "deduct": 3, "note": "无 E 节"}], 3, []
333
+ start, end = seg
334
+ ev_fb = hits(lines, start, end, r"fallback|兜底|一旦|若|如果")
335
+ ev_stop = hits(lines, start, end, r"判停")
336
+ ev_ret = hits(lines, start, end, r"不辩论|回步骤|重跑")
337
+ checks, deduct = [], 0
338
+ fb_ok = len(ev_fb) >= 3 or (len([h for h in ev_fb if "fallback" in h["text"]]) >= 1 and len(ev_ret) >= 1)
339
+ if not fb_ok:
340
+ checks.append({"id": "d3-c1", "rule": "E 内有显式失败分支编码(fallback/若…则…/兜底)", "status": "fail",
341
+ "evidence": ev_fb[:5], "deduct": 3,
342
+ "note": "rubric: 只写正向缺失败分支扣 >=3;条件词命中=%d" % len(ev_fb)})
343
+ deduct += 3
344
+ else:
345
+ checks.append({"id": "d3-c1", "rule": "E 内有显式失败分支编码(fallback/若…则…/兜底)", "status": "pass",
346
+ "evidence": ev_fb[:4], "deduct": 0, "note": ""})
347
+ if not ev_stop:
348
+ checks.append({"id": "d3-c2", "rule": "有 判停/命中即停 显式编码", "status": "fail",
349
+ "evidence": [], "deduct": 3, "note": "无判停分支"})
350
+ deduct += 3
351
+ else:
352
+ checks.append({"id": "d3-c2", "rule": "有 判停/命中即停 显式编码", "status": "pass",
353
+ "evidence": ev_stop[:4], "deduct": 0, "note": ""})
354
+ if not ev_ret:
355
+ checks.append({"id": "d3-c3", "rule": "有恢复路径(回步骤/重跑/不辩论)", "status": "warn",
356
+ "evidence": [], "deduct": 0, "note": "机器未命中恢复词,交 judge 复核"})
357
+ else:
358
+ checks.append({"id": "d3-c3", "rule": "有恢复路径(回步骤/重跑/不辩论)", "status": "pass",
359
+ "evidence": ev_ret[:3], "deduct": 0, "note": ""})
360
+ semantic = ["失败分支是否语义充分(rubric: 只写正向缺失败分支扣>=3,需 judge 语义裁决)"]
361
+ return checks, deduct, semantic
362
+
363
+ def check_d4(lines):
364
+ # v0.2 修正(M4):原把**说明性/历史性**的 🔴(如「(🔴 已修)」「🔴 已登记」)也计入红灯数,
365
+ # 导致 note 里的 red 数虚高。现分列「全部 🔴 行」与「功能性红灯」,判态仍按功能性计数。
366
+ _red_lines = [ln for ln in lines if "\U0001F534" in ln]
367
+ _red_all = len(_red_lines)
368
+
369
+ def _functional_red(ln):
370
+ if re.search(r"(\s*\U0001F534[^)]{0,24}(已修|已登记|历史|原判|曾)", ln):
371
+ return False
372
+ if re.search(r"\U0001F534\s*(已修|已登记|已消解)", ln):
373
+ return False
374
+ return True
375
+
376
+ n_red = sum(1 for ln in _red_lines if _functional_red(ln))
377
+ n_ck = sum(1 for ln in lines if re.search(r"CHECKPOINT|STOP", ln))
378
+ n_confirm = count_in(lines, 0, len(lines), r"确认|呈请")
379
+ checks, deduct = [], 0
380
+ if n_red >= 1 and (n_ck >= 1 or n_confirm >= 1):
381
+ ev = hits(lines, 0, len(lines), "\U0001F534" + r"\s*(CHECKPOINT|判停)")
382
+ checks.append({"id": "d4-c1", "rule": "显性检查点标记 🔴/STOP/CHECKPOINT 存在(rubric: 仅'如果...建议'不算)",
383
+ "status": "pass", "evidence": ev[:4], "deduct": 0,
384
+ "note": "red_functional=%d ck=%d(全部 🔴 行 %d,已排除说明/历史标注 %d)"
385
+ % (n_red, n_ck, _red_all, _red_all - n_red)})
386
+ else:
387
+ checks.append({"id": "d4-c1", "rule": "显性检查点标记 🔴/STOP/CHECKPOINT 存在",
388
+ "status": "fail", "evidence": [], "deduct": 4,
389
+ "note": "机器未命中显性标记;仅软措辞=不达标"})
390
+ deduct += 4
391
+ semantic = ["检查点是否在'关键决策前'、用户介入粒度是否够(rse judge 曾因唯一确认点扣1;机器不判)"]
392
+ return checks, deduct, semantic
393
+
394
+ def check_d5(lines):
395
+ end = len(lines)
396
+ checks, deduct = [], 0
397
+ soft_hits = []
398
+ for i in range(end):
399
+ for w in SOFT_HARD:
400
+ if w in lines[i]:
401
+ t = lines[i].strip()
402
+ if len(t) > 110:
403
+ t = t[:107] + "..."
404
+ soft_hits.append({"line": i + 1, "text": t, "word": w})
405
+ seen = {}
406
+ for h in soft_hits:
407
+ seen.setdefault(h["word"], []).append(h["line"])
408
+ hard_total = sum(len(v) for v in seen.values())
409
+ if hard_total >= 3:
410
+ checks.append({"id": "d5-c1", "rule": "软化空话词(硬词表) <3 处", "status": "fail",
411
+ "evidence": soft_hits[:6], "deduct": 3,
412
+ "note": "rubric 明示:>=3 处扣 >=3"})
413
+ deduct += 3
414
+ elif hard_total > 0:
415
+ checks.append({"id": "d5-c1", "rule": "软化空话词 <3 处", "status": "warn",
416
+ "evidence": soft_hits[:6], "deduct": 0,
417
+ "note": "1-2 处 rubric 未给幅度;列出候选交 judge"})
418
+ else:
419
+ checks.append({"id": "d5-c1", "rule": "软化空话词(硬词表) 0 命中", "status": "pass",
420
+ "evidence": [], "deduct": 0, "note": ""})
421
+ n_tbl = count_in(lines, 0, end, r"^\|.*\|")
422
+ n_ex = count_in(lines, 0, end, r"示例|话术|模板")
423
+ if n_tbl >= 5 and n_ex >= 2:
424
+ checks.append({"id": "d5-c2", "rule": "可执行正证据:表格/示例/模板", "status": "pass",
425
+ "evidence": [{"line": 1, "text": "tables=%d examples=%d" % (n_tbl, n_ex)}],
426
+ "deduct": 0, "note": ""})
427
+ else:
428
+ checks.append({"id": "d5-c2", "rule": "可执行正证据:表格/示例/模板", "status": "warn",
429
+ "evidence": [], "deduct": 0, "note": "机器只见计数,参数含金量交 judge"})
430
+ semantic = ["'建议'类词的语境豁免(审计对象语义/示例引语内不算软化)、超长单行排版对可执行性的影响(rse judge 曾扣1;机器不判)"]
431
+ return checks, deduct, semantic
432
+
433
+ def check_d6(lines, meta, skill_path):
434
+ checks, deduct = [], 0
435
+ sp = os.path.abspath(skill_path)
436
+ parts = sp.split(os.sep)
437
+ slug_self = os.path.basename(os.path.dirname(sp))
438
+ host_skill = _is_host_skill(sp) # v4.3:区分"宿主技能"与"书册原稿技能"
439
+ book_base = None
440
+ book_source = ""
441
+ if BOOK_BASE_OVERRIDE:
442
+ book_base, book_source = BOOK_BASE_OVERRIDE, "override"
443
+ elif host_skill and not LEGACY_BOOK_CHECKS:
444
+ book_base = _auto_book_base(slug_self)
445
+ book_source = "auto" if book_base else "none"
446
+ elif "skills" in parts:
447
+ idx = parts.index("skills")
448
+ book_base = os.sep.join(parts[:idx])
449
+ book_source = "derived"
450
+ rel = meta.get("related_skills", "")
451
+ slugs = sorted(set(re.findall(r"[a-z][a-z0-9-]+", rel)))
452
+ slugs = [s for s in slugs if s not in ("skills", "slug") and len(s) > 2] # v3.5: "slug" 为 YAML 注释元词,误提→d6-c1 假阳性
453
+ found = set()
454
+ # v4.3 默认:宿主技能的"实存集合"不含原稿副本(引擎不加载原稿树;含进去会漏判归档死链)
455
+ _skip_drafts = HOST_ONLY_REFS and host_skill
456
+ if not _skip_drafts:
457
+ for cand in _g.glob(os.path.join(BASE_DIR, "投资蒸馏", "**", "skills", "*", "SKILL.md"), recursive=True):
458
+ found.add(os.path.basename(os.path.dirname(cand)))
459
+ # v4.1 补丁(2026-09-12):宿主技能目录并入 found,消除"投资家族占位"注释依赖(backward-compatible)
460
+ # v4.4(2026-09-13):宿主技能根改为**从被检文件反推**(修"硬编码金融宿主"缺陷);
461
+ # 反推不到才回落到旧常量(旧行为不变,仅非宿主技能才会走到回落)。
462
+ host_skills = _host_skills_root(sp) or os.path.join(os.path.dirname(BASE_DIR), "金融投资", ".dsh", "skills")
463
+ if os.path.isdir(host_skills):
464
+ for _n in os.listdir(host_skills):
465
+ if os.path.isdir(os.path.join(host_skills, _n)):
466
+ found.add(_n)
467
+ missing = [s for s in slugs if s not in found]
468
+ family_hint = []
469
+ real_missing = []
470
+ for s in missing:
471
+ # 粗略判断是否为"投资家族"占位:related 文本中该 slug 后面带(投资家族
472
+ i = rel.find(s)
473
+ after = rel[i + len(s):i + len(s) + 24] if i >= 0 else ""
474
+ if "投资家族" in after or s in ("trader-discipline", "fund-selector", "investing-mindset"):
475
+ family_hint.append(s)
476
+ else:
477
+ real_missing.append(s)
478
+ if family_hint or real_missing:
479
+ checks.append({"id": "d6-c1", "rule": "related_skills slug 全库实存", "status": "warn" if not real_missing else "fail",
480
+ "evidence": [{"line": 7, "text": "missing=" + ",".join(missing)}],
481
+ "deduct": len(real_missing),
482
+ "note": "投资家族占位(warn 不扣):" + ",".join(family_hint) + " 实缺:" + ",".join(real_missing)})
483
+ deduct += len(real_missing)
484
+ else:
485
+ checks.append({"id": "d6-c1", "rule": "related_skills slug 全库实存", "status": "pass",
486
+ "evidence": [], "deduct": 0, "note": ""})
487
+ refs = []
488
+ for i, ln in enumerate(lines):
489
+ for m in POOL_FILE_RE.finditer(ln):
490
+ refs.append({"line": i + 1, "ref": m.group(0)})
491
+ # v4.3:宿主技能查不到原书册根时(如宿主原创技能),池类检查无法定位 candidates/ →
492
+ # 发 skip(deduct 0),不再像旧口径那样"用错误路径恒报 fail"。
493
+ skip_book_checks = (book_base is None) and host_skill and not LEGACY_BOOK_CHECKS
494
+ if skip_book_checks:
495
+ checks.append({"id": "d6-c2", "rule": "文本内 candidates/*.md 引用可达", "status": "skip",
496
+ "evidence": refs[:3], "deduct": 0,
497
+ "note": "宿主技能且无书册根(book_base=none)→ 无法定位 candidates/,跳过;"
498
+ "如已知原书册根,用 --book-base <dir> 复核"})
499
+ checks.append({"id": "d6-c3", "rule": "池代号族均有对应池文件", "status": "skip", "evidence": [], "deduct": 0,
500
+ "note": "同上,跳过(旧口径在宿主技能上恒 fail,属结构性假阳性)"})
501
+ checks.append({"id": "d6-c5", "rule": "池 id 在对应池文件内实存", "status": "skip", "evidence": [], "deduct": 0,
502
+ "note": "同上,跳过"})
503
+ bad_refs = []
504
+ if book_base:
505
+ for r in refs:
506
+ p = os.path.join(book_base, r["ref"].replace("/", os.sep))
507
+ if not os.path.isfile(p):
508
+ bad_refs.append(r)
509
+ fam_used = set()
510
+ fam_mapped = set()
511
+ for ln in lines:
512
+ for m in POOL_ID_RE.finditer(ln):
513
+ fam_used.add(m.group(1))
514
+ if "candidates/" in ln:
515
+ for m in POOL_FILE_RE.finditer(ln):
516
+ base_f = os.path.basename(m.group(0))
517
+ fam_mapped.add({v: k for k, v in FAM_FILE.items()}.get(base_f, "ce")) # v3.2: 按文件名反查族(旧版只认 ce 族→误 -3)
518
+ # v3.5 fix (d6-c3 池族映射 bug):族映射 = 内联引用 ∪ 磁盘自动发现;文件在但没内联=▲,文件不存在=🔴
519
+ fam_on_disk = set()
520
+ if book_base:
521
+ for fam in fam_used:
522
+ fn = FAM_FILE.get(fam)
523
+ if fn and os.path.isfile(os.path.join(book_base, "candidates", fn)):
524
+ fam_on_disk.add(fam)
525
+ missing_file = sorted(fam_used - fam_on_disk) # 真缺陷:用了族但池文件不存在
526
+ inline_missing = sorted(fam_on_disk - fam_mapped) # 文件在但文本没明文引用(▲级)
527
+ if not skip_book_checks:
528
+ if refs:
529
+ checks.append({"id": "d6-c2", "rule": "文本内 candidates/*.md 引用可达", "status": "fail" if bad_refs else "pass",
530
+ "evidence": refs[:5], "deduct": len(bad_refs),
531
+ "note": "bad_refs=" + ",".join(r["ref"] for r in bad_refs)})
532
+ deduct += len(bad_refs)
533
+ if missing_file:
534
+ checks.append({"id": "d6-c3", "rule": "池代号族均有对应池文件(磁盘自动发现)", "status": "fail",
535
+ "evidence": refs[:5], "deduct": len(missing_file),
536
+ "note": "missing_file=" + ",".join(missing_file) + ";池文件不存在,id 无法实存核验"})
537
+ deduct += len(missing_file)
538
+ else:
539
+ checks.append({"id": "d6-c3", "rule": "池代号族均有对应池文件", "status": "pass", "evidence": [], "deduct": 0,
540
+ "note": ("内联映射缺但文件在: " + ",".join(inline_missing)) if inline_missing else ""})
541
+ # c5 池 id 实存核验(v2 新增:按家族约定文件自动发现,SKILL 引用 id 是否在池文件内存在)
542
+ id_report = []
543
+ if book_base:
544
+ for fam in sorted(fam_used):
545
+ fn = FAM_FILE.get(fam)
546
+ if not fn:
547
+ continue
548
+ p = os.path.join(book_base, "candidates", fn)
549
+ if not os.path.isfile(p):
550
+ id_report.append({"family": fam, "file": fn, "file_exists": False, "used": 0, "missing_ids": []})
551
+ continue
552
+ with open(p, encoding="utf-8") as fh:
553
+ pool_text = fh.read()
554
+ frx = re.compile("(?<![A-Za-z0-9])" + fam + r"(\d{2})")
555
+ pool_ids = set(int(m.group(1)) for m in frx.finditer(pool_text))
556
+ srx = re.compile("(?<![A-Za-z0-9])" + fam + r"(\d{2})")
557
+ used_ids = set(int(m.group(1)) for m in srx.finditer("\n".join(lines)))
558
+ miss = sorted(used_ids - pool_ids)
559
+ id_report.append({"family": fam, "file": fn, "file_exists": True, "used": len(used_ids), "missing_ids": miss})
560
+ if id_report and not skip_book_checks:
561
+ bad = [x for x in id_report if not x["file_exists"] or x["missing_ids"]]
562
+ checks.append({"id": "d6-c5", "rule": "池 id 在对应池文件内实存(自动发现族文件,v2 新增)",
563
+ "status": "fail" if bad else "pass",
564
+ "evidence": [{"line": 1, "text": json.dumps(id_report, ensure_ascii=False)}],
565
+ "deduct": 0,
566
+ "note": "缺 id 或文件缺失: " + ("无" if not bad else str(bad))})
567
+ # c4 家族路由实存核验(信息性:trv judge 卡曾称投资家族路由"全库不存在",实存于其他册)
568
+ # v0.2 修正(M8):原版对「家族路由实存」只硬编码核验 3 个 slug 并自认口径不确定。
569
+ # 现改为:解析本技能 frontmatter.related_skills 与正文 `反引号 slug`,对**全部被引用 slug**核验
570
+ # —— 在三处搜索:蒸馏树 skills ∪ 宿主一级 skills ∪ 宿主 archived(不可加载者单列)。
571
+ _doc = "\n".join(lines)
572
+ _refs = set()
573
+ _m = re.search(r"related_skills:\s*\[(.*?)\]", _doc, re.S)
574
+ if _m:
575
+ for x in re.split(r"[,\n]", _m.group(1)):
576
+ x = re.sub(r"[((].*?[))]", "", x).strip().strip('"').strip("'")
577
+ if x and "-" in x:
578
+ _refs.add(x)
579
+ for x in re.findall(r"`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)`", _doc):
580
+ _refs.add(x)
581
+ _tree, _host, _arch = set(), set(), set()
582
+ for _p in _g.glob(os.path.join(BASE_DIR, "投资蒸馏", "*", "skills", "*")):
583
+ if os.path.isdir(_p):
584
+ _tree.add(os.path.basename(_p))
585
+ # v4.4(2026-09-13):同上,改为从被检文件反推宿主技能根(回落常量保持旧行为)。
586
+ _hroot = _host_skills_root(skill_path) or r"D:\deepseekharness\workspaces\金融投资\.dsh\skills"
587
+ if os.path.isdir(_hroot):
588
+ _host = {d for d in os.listdir(_hroot) if os.path.isdir(os.path.join(_hroot, d))}
589
+ _ap = os.path.join(_hroot, "archived")
590
+ if os.path.isdir(_ap):
591
+ _arch = {d for d in os.listdir(_ap) if os.path.isdir(os.path.join(_ap, d))}
592
+ _miss = sorted(x for x in _refs if x not in _tree and x not in _host)
593
+ _arch_only = sorted(x for x in _miss if x in _arch)
594
+ _real_miss = sorted(x for x in _miss if x not in _arch)
595
+ checks.append({"id": "d6-c4", "rule": "本技能引用的全部 slug 实存核验(树 ∪ 宿主 ∪ 宿主 archived)",
596
+ "status": "pass" if not _real_miss else "warn",
597
+ "evidence": [{"line": 1, "text": json.dumps({"referenced": len(_refs), "real_missing": _real_miss,
598
+ "archived_only": _arch_only}, ensure_ascii=False)}],
599
+ "deduct": 0,
600
+ "note": "引用 %d 个 slug;真缺 %d;仅存在于宿主 archived(引擎只扫一级子目录→不可加载)%d%s"
601
+ % (len(_refs), len(_real_miss), len(_arch_only),
602
+ (";真缺清单=" + ",".join(_real_miss)) if _real_miss else "")})
603
+ semantic = ["'代号生态真实可达但粒度不够直接可达'的行文判断(judge 语义)", "家族式简写路径约定是否可接受"]
604
+ return checks, min(deduct, 3), semantic
605
+
606
+ def check_d7(lines):
607
+ checks, deduct = [], 0
608
+ rseg = find_section(lines, r"^##\s*R")
609
+ def is_quote_line(i, ln):
610
+ if rseg is not None and rseg[0] <= i < rseg[1]:
611
+ return True
612
+ if ln.lstrip().startswith(">"):
613
+ return True
614
+ if "「" in ln and "」" in ln:
615
+ return True
616
+ return False
617
+ hits_all = []
618
+ for i, ln in enumerate(lines):
619
+ if is_quote_line(i, ln):
620
+ continue
621
+ for w in AI_BANNED:
622
+ if w in ln:
623
+ hits_all.append({"line": i + 1, "text": ln.strip()[:110], "word": w})
624
+ if hits_all:
625
+ checks.append({"id": "d7-c1", "rule": "AI 腔禁词 0 命中(rubric: 一处扣1)", "status": "fail",
626
+ "evidence": hits_all[:6], "deduct": min(3, len(hits_all)),
627
+ "note": "禁词命中 %d 处" % len(hits_all)})
628
+ deduct += min(3, len(hits_all))
629
+ else:
630
+ checks.append({"id": "d7-c1", "rule": "AI 腔禁词 0 命中", "status": "pass", "evidence": [], "deduct": 0, "note": ""})
631
+ need = {"## R": False, "## I": False, "## E": False, "## B": False}
632
+ for ln in lines:
633
+ for k in need:
634
+ if ln.startswith(k):
635
+ need[k] = True
636
+ has_update = any("数据更新指引" in ln for ln in lines)
637
+ if all(need.values()) and has_update:
638
+ checks.append({"id": "d7-c2", "rule": "分节骨架齐全(R/I/E/B/数据更新指引)", "status": "pass",
639
+ "evidence": [], "deduct": 0, "note": ""})
640
+ else:
641
+ checks.append({"id": "d7-c2", "rule": "分节骨架齐全(R/I/E/B/数据更新指引)", "status": "warn",
642
+ "evidence": [], "deduct": 0,
643
+ "note": "missing=" + ",".join(k for k, v in need.items() if not v)})
644
+ echo = {}
645
+ for i, ln in enumerate(lines):
646
+ for ph in ECHO_PHRASES:
647
+ if ph in ln:
648
+ echo.setdefault(ph, []).append(i + 1)
649
+ echo_warn = {k: v for k, v in echo.items() if len(v) >= 4}
650
+ if echo_warn:
651
+ checks.append({"id": "d7-c3", "rule": "回声计数(信息性)", "status": "warn",
652
+ "evidence": [{"line": v[0], "text": "%s 出现 %d 行: %s" % (k, len(v), ",".join(map(str, v)))} for k, v in list(echo_warn.items())[:3]],
653
+ "deduct": 0, "note": "交 judge 判断是否构成冗余扣分"})
654
+ semantic = ["AI 腔词在原文引文/用户引句内的豁免(机器已跳过 R 段/「」引句/>块引用)",
655
+ "层次紧凑性/冗余回声(trv judge 曾因 4 处路由回声扣1;机器只报回声计数不扣)",
656
+ "文件体积/超长行对维护与注入的代价(rse judge 曾扣1;机器不判)"]
657
+ return checks, deduct, semantic
658
+
659
+ def check_d9(lines):
660
+ checks, deduct = [], 0
661
+ seg_b4 = find_section(lines, r"^###\s*B4")
662
+ ev_b4, n_dash, n_num = [], 0, 0
663
+ if seg_b4:
664
+ # v0.2 修正(M2):原只数 "- " 项目符号,本册 B4 用「1. 2. 3.」编号项 → 误报 0 条。现兼容两种。
665
+ ev_b4 = hits(lines, seg_b4[0], seg_b4[1], r"^\s*(-|\d+\.)\s")
666
+ n_dash = count_in(lines, seg_b4[0], seg_b4[1], r"^\s*-\s")
667
+ n_num = count_in(lines, seg_b4[0], seg_b4[1], r"^\s*\d+\.\s")
668
+ has_speed = any("判停速查" in ln for ln in lines)
669
+ has_b3 = find_section(lines, r"^###\s*B3") is not None
670
+ has_nodo = any(("不要做" in ln or "反例" in ln) for ln in lines)
671
+ if seg_b4 and has_nodo:
672
+ checks.append({"id": "d9-c1", "rule": "独立'不要做'反例章(B4/反例池)存在", "status": "pass",
673
+ "evidence": ev_b4[:5], "deduct": 0,
674
+ "note": "B4 反例条数=%d(- 项 %d + 编号项 %d)" % (len(ev_b4), n_dash, n_num)})
675
+ else:
676
+ checks.append({"id": "d9-c1", "rule": "独立'不要做'反例章存在(rubric: 只写正向扣>=3)", "status": "fail",
677
+ "evidence": [], "deduct": 3, "note": "未命中 B4/反例"})
678
+ deduct += 3
679
+ if has_speed:
680
+ checks.append({"id": "d9-c2", "rule": "红灯动作速查(判停速查)单独成节", "status": "pass",
681
+ "evidence": hits(lines, 0, len(lines), r"判停速查")[:2], "deduct": 0, "note": ""})
682
+ else:
683
+ checks.append({"id": "d9-c2", "rule": "红灯动作速查单独成节", "status": "warn", "evidence": [], "deduct": 0,
684
+ "note": "未命中'判停速查'专节,交 judge 复核"})
685
+ if has_b3:
686
+ checks.append({"id": "d9-c3", "rule": "限定语纪律(B3)存在", "status": "pass", "evidence": [], "deduct": 0, "note": ""})
687
+ semantic = ["反例条目语义质量/与正文咬合(机器只查存在性与条数)"]
688
+ return checks, deduct, semantic
689
+
690
+ # ----------------------------- 主流程 -----------------------------
691
+
692
+ DIM_W = {"d1": 7, "d2": 12, "d3": 12, "d4": 6, "d5": 18, "d6": 4, "d7": 12, "d8": 23, "d9": 6}
693
+ DIM_NAME = {"d1": "Frontmatter 质量", "d2": "工作流清晰度", "d3": "失败模式编码", "d4": "检查点设计",
694
+ "d5": "可执行具体性", "d6": "资源整合度", "d7": "整体架构", "d8": "实测表现", "d9": "反例与黑名单"}
695
+ COVER = {"d1": "partial", "d2": "partial", "d3": "partial", "d4": "partial", "d5": "partial",
696
+ "d6": "partial", "d7": "partial", "d8": "none", "d9": "full"}
697
+
698
+ def run(skill_path):
699
+ text, lines = read_lines(skill_path)
700
+ meta, desc = frontmatter_meta(lines)
701
+ slug = os.path.basename(os.path.dirname(os.path.abspath(skill_path)))
702
+ fns = {
703
+ "d1": lambda: check_d1(lines, meta, desc),
704
+ "d2": lambda: check_d2(lines),
705
+ "d3": lambda: check_d3(lines),
706
+ "d4": lambda: check_d4(lines),
707
+ "d5": lambda: check_d5(lines),
708
+ "d6": lambda: check_d6(lines, meta, skill_path),
709
+ "d7": lambda: check_d7(lines),
710
+ "d8": lambda: ([], 0, ["d8=运行期实测(full_test/dry_run),机器初评不参与;judge 保留此维"]),
711
+ "d9": lambda: check_d9(lines),
712
+ }
713
+ dims = {}
714
+ for d in ["d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9"]:
715
+ checks, deduct, sem = fns[d]()
716
+ cov = COVER[d]
717
+ dims[d] = {
718
+ "dim": d, "name": DIM_NAME[d], "weight": DIM_W[d], "coverage": cov,
719
+ "checks": checks, "machine_deduct": deduct,
720
+ "machine_raw": None if cov == "none" else max(1, 10 - deduct),
721
+ "semantic_only_notes": sem,
722
+ }
723
+ machine_static = sum(dims[d]["machine_raw"] * DIM_W[d] for d in dims if dims[d]["machine_raw"] is not None) / 10.0
724
+ # v2: test-prompts 盘点 + runtime 红灯扫描(信息性,进 machine-report)
725
+ skill_dir = os.path.dirname(os.path.abspath(skill_path))
726
+ tp = {"exists": False}
727
+ tp_path = os.path.join(skill_dir, "test-prompts.json")
728
+ # v0.2(M6):并报**册级** test-prompts(技能目录的同级 skills/test-prompts.json),
729
+ # 避免「逐技能 10+10+9=29」与「册级 18 探针」被当成同一产物而口径混淆。
730
+ _book_tp = os.path.join(os.path.dirname(skill_dir), "test-prompts.json")
731
+ _book_tp_info = None
732
+ if os.path.isfile(_book_tp):
733
+ try:
734
+ _bd = json.load(open(_book_tp, encoding="utf-8"))
735
+ _n = len(_bd.get("probes", [])) if isinstance(_bd, dict) else (len(_bd) if isinstance(_bd, list) else None)
736
+ _book_tp_info = {"file": "skills/test-prompts.json", "count": _n,
737
+ "note": "册级路由盲测集(与逐技能测试集为两个不同产物)"}
738
+ except Exception as _e:
739
+ _book_tp_info = {"file": "skills/test-prompts.json", "error": str(_e)[:60]}
740
+ if os.path.isfile(tp_path):
741
+ try:
742
+ with open(tp_path, encoding="utf-8") as fh:
743
+ _d = json.load(fh)
744
+ ids = [str(x.get("id")) for x in _d if isinstance(x, dict) and "id" in x]
745
+ tp = {"exists": True, "count": len(_d) if isinstance(_d, list) else None, "ids": ids[:16],
746
+ "scope": "skill_dir(逐技能测试集)", "book_level": _book_tp_info}
747
+ except Exception as exc:
748
+ tp = {"exists": True, "parse_error": str(exc)}
749
+ rt = [{"line": i + 1, "text": ln.strip()[:100]} for i, ln in enumerate(lines) if RT_RX.search(ln)]
750
+ report = {
751
+ "schema": "machine-precheck-v2",
752
+ "skill": slug,
753
+ "file": os.path.abspath(skill_path),
754
+ "lines": len(lines),
755
+ "bytes": len(text.encode("utf-8")),
756
+ "test_prompts": tp,
757
+ "runtime_red_scan": {"hits": len(rt), "evidence": rt[:6]},
758
+ "dims": dims,
759
+ "summary": {
760
+ "machine_covered_weight": 77,
761
+ "static_weight_excl_d8": 77,
762
+ "machine_static_score": round(machine_static, 2),
763
+ },
764
+ }
765
+ return report
766
+
767
+ def main():
768
+ global HOST_ONLY_REFS, BOOK_BASE_OVERRIDE, LEGACY_BOOK_CHECKS
769
+ if len(sys.argv) < 2:
770
+ print("usage: python machine_precheck_v2.py <SKILL.md> [--out out.json] "
771
+ "[--book-base <该书册根目录>] [--drafts-in-refs] [--legacy-book-checks]")
772
+ sys.exit(2)
773
+ if "--drafts-in-refs" in sys.argv: # 复现 v4.2 之前的 d6-c1 旧口径
774
+ HOST_ONLY_REFS = False
775
+ if "--legacy-book-checks" in sys.argv: # 复现 v4.2 之前的 d6-c2/c3/c5 旧口径
776
+ LEGACY_BOOK_CHECKS = True
777
+ if "--host-only-refs" in sys.argv: # v4.2 兼容开关(现已是默认)
778
+ HOST_ONLY_REFS = True
779
+ if "--book-base" in sys.argv:
780
+ BOOK_BASE_OVERRIDE = sys.argv[sys.argv.index("--book-base") + 1]
781
+ skill_path = sys.argv[1]
782
+ report = run(skill_path)
783
+ out = None
784
+ if "--out" in sys.argv:
785
+ out = sys.argv[sys.argv.index("--out") + 1]
786
+ if out is None:
787
+ slug = report["skill"]
788
+ out = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(skill_path)), "..", "..", "machine-report-" + slug + ".json"))
789
+ os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
790
+ with open(out, "w", encoding="utf-8") as f:
791
+ json.dump(report, f, ensure_ascii=False, indent=2)
792
+ print("OK", report["skill"], "machine_static=%.2f" % report["summary"]["machine_static_score"], "->", out)
793
+
794
+ if __name__ == "__main__":
795
+ main()