community_check.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. #!/usr/bin/env python3
  2. """COMMUNITY.md 收录PR的半自动检查。
  3. 机器检查(本脚本):
  4. 1. PR只改动 COMMUNITY.md(动了SKILL.md等核心文件直接判❌)
  5. 2. 新增行里的GitHub仓库真实存在且公开
  6. 3. 若目标仓库含SKILL.md(人物/主题skill类)→ 额外要求:
  7. - FIDELITY.md 存在且总分≥70(B级)
  8. - FIDELITY.md 关键字段行(测试日期/总分/各维评分)不得为占位或预估
  9. (待跑、预估、TBD、YYYY-MM-DD等——分数须为独立双agent实测结果;
  10. 只扫字段行不扫全文,诚实边界等正文表述不会触发)
  11. - SKILL.md 含「诚实边界」章节
  12. - 有 references/ 目录;references/research/ 子目录缺失仅记⚠️请人工确认
  13. 若不含SKILL.md(合集/工具类)→ 仅存在性检查,标注请人工确认类别
  14. 4. 结果贴成PR评论
  15. 人工检查(维护者):伦理红线 + 内容质量抽查 + 合并。
  16. 本地测试:python3 community_check.py --check-repo owner/repo
  17. """
  18. import json
  19. import os
  20. import re
  21. import sys
  22. import urllib.request
  23. API = "https://api.github.com"
  24. # FIDELITY.md 占位/预估检测:结构化字段扫描,命中即判❌。
  25. # 依据:保真度分数必须来自独立双agent实测(见 references/fidelity-scorecard.md),
  26. # 「预填模板+补一行总分」不算跑过测试(先例:PR #70)。
  27. # 设计约束(2026-07-26对抗审查 P1-22/P1-23):
  28. # 1. 只扫「关键字段行」(测试日期/总分/各维评分等),绝不全文搜词——
  29. # 「诚实边界:xx未实测」是项目自己要求写的内容,不能被判成造假;
  30. # 2. 否定语境免疫:「不是自评分」「NOT a self-assessment」不算命中;
  31. # 3. 英文词绑定分数上下文(estimated score),不拦 "Estimated reading time"。
  32. # 宁可漏判交给人工,也不让守规矩的投稿被机器误拒。
  33. # 关键字段行:行首(允许 >、#、*、-、| 等markdown前缀)出现「字段名 + 冒号/表格分隔符」。
  34. # 「评分日期格式统一为YYYY-MM-DD」这类叙述句因字段名后无分隔符,不会被当成字段行。
  35. FIDELITY_KEY_FIELD_RE = re.compile(
  36. r"^[\s>#*\-|]*(?:测试日期|评分日期|测试时间|测试模型|总分|综合得分|最终得分|得分|评分"
  37. r"|维度\s*\d+|立场一致性|风格辨识度|边缘诚实度|来源透明度|结构完整度"
  38. r"|total(?:\s+score)?|overall(?:\s+score)?|final\s+score|test\s+date|score|date)"
  39. r"[\s*]*[::|]",
  40. re.IGNORECASE,
  41. )
  42. # 占位/预估标记(仅在关键字段行内查找)
  43. FIDELITY_PLACEHOLDER_PATTERNS = [
  44. (r"预估", "预估"),
  45. (r"待跑", "待跑"),
  46. (r"待测", "待测"),
  47. (r"待补", "待补"),
  48. (r"待定", "待定"),
  49. (r"待填", "待填"),
  50. (r"占位", "占位"),
  51. (r"未实测", "未实测"),
  52. (r"未测试", "未测试"),
  53. (r"自评分", "自评分"), # 区别于论文引用里的「自评准确率」,裸「自评」不收
  54. (r"YYYY[-/年]?\s*MM", "YYYY-MM-DD(模板日期没填)"),
  55. (r"\bTBD\b", "TBD"),
  56. (r"\bTODO\b", "TODO"),
  57. (r"self[- ]assess\w*", "self-assessed"),
  58. (r"\bestimated\s+(?:score|total|rating|grade)", "estimated score"),
  59. (r"\bplaceholder\b", "placeholder"),
  60. (r"\bNN\s*/\s*\d+", "NN/100(模板分数没填)"),
  61. ]
  62. # 否定语境:命中词紧邻的前文若是否定表达,视为主动澄清而非占位
  63. FIDELITY_NEGATION_RE = re.compile(
  64. r"(?:不是|并非|绝不|从不|不算|不做|没有|不存在|未使用|无"
  65. r"|not(?:\s+an?)?|isn'?t(?:\s+an?)?|never|no)[\s*'\"「」((]*$",
  66. re.IGNORECASE,
  67. )
  68. def find_fidelity_red_flags(text):
  69. """结构化字段扫描FIDELITY.md:只检查关键字段行是否为占位/预估。
  70. 返回命中标记列表(去重、保持顺序)。非字段行(诚实边界、局限说明等正文)不扫描。
  71. """
  72. hits = []
  73. for line in text.splitlines():
  74. if not FIDELITY_KEY_FIELD_RE.match(line):
  75. continue
  76. for pat, label in FIDELITY_PLACEHOLDER_PATTERNS:
  77. for m in re.finditer(pat, line, re.IGNORECASE):
  78. prefix = line[max(0, m.start() - 16):m.start()]
  79. if FIDELITY_NEGATION_RE.search(prefix):
  80. continue # 否定语境(「不是自评分」「NOT a self-assessment」)
  81. hits.append(label)
  82. break
  83. return list(dict.fromkeys(hits))
  84. def gh(path, token, raw=False):
  85. req = urllib.request.Request(API + path)
  86. if token:
  87. req.add_header("Authorization", f"Bearer {token}")
  88. if raw:
  89. req.add_header("Accept", "application/vnd.github.raw+json")
  90. try:
  91. with urllib.request.urlopen(req, timeout=15) as r:
  92. data = r.read().decode("utf-8", "replace")
  93. return data if raw else json.loads(data)
  94. except Exception:
  95. return None
  96. def check_target_repo(slug, token):
  97. """检查被收录的仓库,返回 (是否通过, 检查项列表)。"""
  98. items = []
  99. repo = gh(f"/repos/{slug}", token)
  100. if not repo:
  101. return False, [("❌", f"`{slug}` 仓库不存在或不可访问")]
  102. items.append(("✅", f"[`{slug}`](https://github.com/{slug}) 存在(★{repo.get('stargazers_count', 0)})"))
  103. skill_md = gh(f"/repos/{slug}/contents/SKILL.md", token, raw=True)
  104. if skill_md is None:
  105. items.append(("ℹ️", "无根目录SKILL.md → 按「合集/工具类」处理,请人工确认类别与内容"))
  106. return True, items
  107. items.append(("✅", "含 SKILL.md(按skill类审核)"))
  108. if "诚实边界" in skill_md or "Honest" in skill_md or "honest-limits" in skill_md.lower():
  109. items.append(("✅", "SKILL.md 含诚实边界章节"))
  110. else:
  111. items.append(("❌", "SKILL.md 缺「诚实边界」章节(收录门槛之一)"))
  112. refs = gh(f"/repos/{slug}/contents/references", token)
  113. if isinstance(refs, list) and refs:
  114. items.append(("✅", "含 references/ 目录"))
  115. research = gh(f"/repos/{slug}/contents/references/research", token)
  116. if isinstance(research, list) and research:
  117. items.append(("✅", "含 references/research/ 调研底稿目录"))
  118. else:
  119. items.append(("⚠️", "未见 references/research/ 子目录(CONTRIBUTING期望的调研底稿结构)。"
  120. "若调研底稿直接放在 references/ 下,请维护者人工确认可溯源性即可,不作硬性拦截"))
  121. else:
  122. items.append(("❌", "缺 references/ 调研底稿(skill需自包含可溯源)"))
  123. fidelity = gh(f"/repos/{slug}/contents/FIDELITY.md", token, raw=True)
  124. if fidelity is None:
  125. items.append(("❌", "缺 FIDELITY.md 保真度评分卡(见 references/fidelity-scorecard.md)"))
  126. else:
  127. m = re.search(r"总分[::]\s*(\d+)\s*/\s*100", fidelity)
  128. if not m:
  129. items.append(("❌", "FIDELITY.md 存在但未解析到「总分:NN/100」"))
  130. elif int(m.group(1)) >= 70:
  131. items.append(("✅", f"保真度 {m.group(1)}/100 ≥ 70(B级门槛)"))
  132. else:
  133. items.append(("❌", f"保真度 {m.group(1)}/100 未达B级门槛(70)"))
  134. red_flags = find_fidelity_red_flags(fidelity)
  135. if red_flags:
  136. items.append(("❌", "FIDELITY.md 关键字段(测试日期/总分/评分)检出占位或预估:`"
  137. + "`、`".join(red_flags[:8])
  138. + ("`" if len(red_flags) <= 8 else f"`…等{len(red_flags)}项")
  139. + "。保真度分数须由独立双agent实测产生(方法见 references/fidelity-scorecard.md),"
  140. "预填模板或自评预估分不满足收录门槛,请实测后更新评分卡"))
  141. else:
  142. items.append(("✅", "FIDELITY.md 关键字段未检出占位/预估"))
  143. ok = all(mark != "❌" for mark, _ in items)
  144. return ok, items
  145. def main():
  146. if len(sys.argv) == 3 and sys.argv[1] == "--check-repo":
  147. ok, items = check_target_repo(sys.argv[2], os.environ.get("GITHUB_TOKEN", ""))
  148. for mark, text in items:
  149. print(mark, text)
  150. sys.exit(0 if ok else 1)
  151. token = os.environ["GITHUB_TOKEN"]
  152. repo = os.environ["GITHUB_REPOSITORY"]
  153. pr = os.environ["PR_NUMBER"]
  154. files = gh(f"/repos/{repo}/pulls/{pr}/files?per_page=100", token) or []
  155. names = [f["filename"] for f in files]
  156. lines = ["## 🤖 社区收录检查\n"]
  157. all_ok = True
  158. core_touched = [n for n in names if n != "COMMUNITY.md"]
  159. if core_touched:
  160. all_ok = False
  161. lines.append(f"❌ PR改动了 COMMUNITY.md 以外的文件:`{'`, `'.join(core_touched[:10])}`")
  162. if any(n == "SKILL.md" for n in core_touched):
  163. lines.append("  ⚠️ SKILL.md 是核心资产,不接受外部PR改动(见 CONTRIBUTING.md),请从PR中移除")
  164. else:
  165. lines.append("✅ 只改动 COMMUNITY.md")
  166. added = []
  167. for f in files:
  168. if f["filename"] == "COMMUNITY.md":
  169. for ln in (f.get("patch") or "").splitlines():
  170. if ln.startswith("+") and not ln.startswith("+++"):
  171. added += re.findall(r"github\.com/([\w.-]+/[\w.-]+)", ln)
  172. added = list(dict.fromkeys(s.rstrip(")/") for s in added))
  173. if not added:
  174. all_ok = False
  175. lines.append("❌ 未在新增行中检测到GitHub仓库链接")
  176. for slug in added[:5]:
  177. ok, items = check_target_repo(slug, token)
  178. all_ok = all_ok and ok
  179. lines.append(f"\n**{slug}**")
  180. lines += [f"- {mark} {text}" for mark, text in items]
  181. lines.append("\n---")
  182. lines.append(("✅ **机器检查通过**。" if all_ok else "❌ **机器检查未通过**,请按上述项修改后推送更新(会自动重跑)。"))
  183. lines.append("最终合并前维护者还会人工确认:伦理红线(CONTRIBUTING.md)+ 内容质量抽查。")
  184. body = "\n".join(lines)
  185. req = urllib.request.Request(
  186. f"{API}/repos/{repo}/issues/{pr}/comments",
  187. data=json.dumps({"body": body}).encode(),
  188. headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
  189. method="POST",
  190. )
  191. urllib.request.urlopen(req, timeout=15)
  192. sys.exit(0 if all_ok else 1)
  193. if __name__ == "__main__":
  194. main()