weflow-cli 1.7.0 → 1.8.1

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 (200) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +2108 -2
  3. package/OPERATIONS.md +810 -1
  4. package/README.en.md +96 -10
  5. package/README.md +34 -9
  6. package/bin/weflow-cli.ts +937 -28
  7. package/dist/bin/weflow-cli.js +955 -28
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +25 -6
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +599 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +217 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1116 -59
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/services/wereadService.d.ts +33 -6
  100. package/dist/src/services/wereadService.d.ts.map +1 -1
  101. package/dist/src/services/wereadService.js +27 -2
  102. package/dist/src/services/wereadService.js.map +1 -1
  103. package/dist/src/utils/atomicWrite.d.ts +4 -0
  104. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  105. package/dist/src/utils/atomicWrite.js +60 -0
  106. package/dist/src/utils/atomicWrite.js.map +1 -0
  107. package/dist/src/utils/dateRange.d.ts +15 -0
  108. package/dist/src/utils/dateRange.d.ts.map +1 -1
  109. package/dist/src/utils/dateRange.js +47 -0
  110. package/dist/src/utils/dateRange.js.map +1 -1
  111. package/dist/src/utils/text.d.ts +18 -0
  112. package/dist/src/utils/text.d.ts.map +1 -0
  113. package/dist/src/utils/text.js +20 -0
  114. package/dist/src/utils/text.js.map +1 -0
  115. package/docs/AI_INTERFACE.md +2 -0
  116. package/docs/BRANCHES.md +13 -4
  117. package/docs/DECISIONS.md +1424 -0
  118. package/docs/EXTENDING.md +187 -0
  119. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  120. package/docs/MCP.md +23 -4
  121. package/docs/PROJECT_STATE.md +210 -9
  122. package/docs/SETUP.md +22 -0
  123. package/docs/SYNC_CONTRACT.md +31 -3
  124. package/mcp-server/index.ts +681 -657
  125. package/package.json +84 -83
  126. package/resources/panel/ball-position.cjs +153 -0
  127. package/resources/panel/index.html +46 -0
  128. package/resources/panel/main.cjs +483 -0
  129. package/resources/panel/mascot-happy.png +0 -0
  130. package/resources/panel/mascot-sorry.png +0 -0
  131. package/resources/panel/mascot-thinking.png +0 -0
  132. package/resources/panel/mascot-tired.png +0 -0
  133. package/resources/panel/mascot.png +0 -0
  134. package/resources/panel/package.json +7 -0
  135. package/resources/panel/panel.css +415 -0
  136. package/resources/panel/preload.cjs +79 -0
  137. package/resources/panel/quick-menu.cjs +197 -0
  138. package/resources/panel/renderer.js +470 -0
  139. package/resources/panel/tray-menu.cjs +40 -0
  140. package/resources/panel/tray.png +0 -0
  141. package/scripts/_utils.py +425 -54
  142. package/scripts/annual_report.py +4 -2
  143. package/scripts/article_notes.py +423 -0
  144. package/scripts/backfill_articles.py +438 -0
  145. package/scripts/biz_daily.py +590 -105
  146. package/scripts/chat_notes.py +470 -0
  147. package/scripts/classify_daily.py +10 -18
  148. package/scripts/compile_wiki.py +1060 -46
  149. package/scripts/create_reading_notes.py +199 -14
  150. package/scripts/decide.py +253 -0
  151. package/scripts/draft_reply.py +671 -0
  152. package/scripts/export_chat_html.py +35 -33
  153. package/scripts/extract_todos.py +31 -5
  154. package/scripts/fav_notes.py +318 -0
  155. package/scripts/fix_topics.py +2 -1
  156. package/scripts/generate_ai_report.py +214 -9
  157. package/scripts/generate_html.py +18 -3
  158. package/scripts/generate_review.py +2 -2
  159. package/scripts/jev_client.py +294 -0
  160. package/scripts/jev_probe.py +440 -0
  161. package/scripts/nt_common.py +82 -0
  162. package/scripts/nt_decrypt.py +1755 -1460
  163. package/scripts/pipeline.py +27 -2
  164. package/scripts/promote_ideas.py +19 -4
  165. package/scripts/quality_eval.py +707 -0
  166. package/scripts/rag_chat.py +4 -4
  167. package/scripts/read_image.py +187 -0
  168. package/scripts/reply_debt.py +488 -0
  169. package/scripts/route_cards.py +453 -0
  170. package/scripts/semantic_search.py +208 -49
  171. package/scripts/user_notes.py +315 -0
  172. package/scripts/vault_rag.py +13 -7
  173. package/scripts/vault_search.py +21 -4
  174. package/scripts/wiki_lint.py +429 -0
  175. package/src/core/appMsgFormat.ts +117 -0
  176. package/src/core/ntCore.ts +27 -2
  177. package/src/core/sqlcipherCore.ts +5 -61
  178. package/src/core/wcdbCore.ts +6 -2
  179. package/src/panel/client.ts +49 -0
  180. package/src/panel/endpoint.ts +144 -0
  181. package/src/panel/launch.ts +101 -0
  182. package/src/panel/server.ts +424 -0
  183. package/src/panel/userId.ts +72 -0
  184. package/src/services/assistantDaemon.ts +57 -9
  185. package/src/services/assistantEval.ts +611 -0
  186. package/src/services/assistantMemory.ts +413 -57
  187. package/src/services/assistantPrivacy.ts +25 -6
  188. package/src/services/assistantRouter.ts +191 -0
  189. package/src/services/assistantService.ts +681 -74
  190. package/src/services/assistantTools.ts +1149 -58
  191. package/src/services/assistantTrace.ts +175 -0
  192. package/src/services/chatService.ts +6 -2
  193. package/src/services/configService.ts +403 -385
  194. package/src/services/pythonBridge.ts +139 -0
  195. package/src/services/syncService.ts +37 -9
  196. package/src/services/wechatMessageService.ts +73 -7
  197. package/src/services/wereadService.ts +43 -7
  198. package/src/utils/atomicWrite.ts +56 -0
  199. package/src/utils/dateRange.ts +46 -0
  200. package/src/utils/text.ts +20 -0
@@ -0,0 +1,707 @@
1
+ #!/usr/bin/env python3
2
+ """定标:把"不知道准不准"变成一条曲线。
3
+
4
+ 这一整轮工作里所有概率都缺同一件东西——**金标准**。没有它,"一致率 88.6%" 只是两个
5
+ 都不完美的仪器在互相比,而阈值(收录 0.5、相关度 0.5/1.5)全是我拍的。
6
+
7
+ 这个脚本不替你判断,它把"你要花多久"从"手工整理 50 篇"压到"照着编号填 50 行":
8
+
9
+ # 1. 抽样:现场用**生产路径**给每篇打分,同时导出给人看的清单
10
+ python scripts/quality_eval.py sample --n 50
11
+
12
+ # 2. 打开 ~/.weflow-cli/labels/<时间戳>.json,把每条的 label 填上:
13
+ # {"topic": "学术", "relevance": "中", "include": true} —— 三栏,一分钟能填十几条
14
+ # 没把握的留 null,它会被算作"未标注"而不是"标错"
15
+ # (打印出来的那张清单是**盲的**:不显示模型的答案,否则一致率会被锚高)
16
+
17
+ # 3. 算账:准确率、**校准曲线**、以及阈值应该定在哪
18
+ python scripts/quality_eval.py score ~/.weflow-cli/labels/<时间戳>.json
19
+
20
+ **为什么现场打分而不是用存档标签**:磁盘上的文章绝大多数生成于概率字段引入之前,
21
+ 没有 includeScore 可对;而且存档的 topic 本身就是要被检验的那个东西(实测过它一天之内
22
+ 把 364 篇全判成"学术")。所以样本必须现场用生产路径打分,才有 (概率, 人工标签) 配对。
23
+
24
+ **分数会按文章路径缓存**(`~/.weflow-cli/labels/.jev-scores.json`):模型每次给的分数会有
25
+ 微小差异,跨过一个档位边界就换掉整批样本(实测同一条命令连跑两次,50 条里 7 个位置不同)。
26
+ 缓存之后 `--seed` 才真的定住样本,顺带重跑不再花额度;模型换了或判据改了用 `--refresh`。
27
+
28
+ **它不写进仓库**:标签文件落在 `~/.weflow-cli/labels/`,因为里面是文章标题与你的判断。
29
+ """
30
+ import argparse
31
+ import glob
32
+ import json
33
+ import os
34
+ import random
35
+ import re
36
+ import sys
37
+ import time
38
+ from collections import defaultdict
39
+ from concurrent.futures import ThreadPoolExecutor, as_completed
40
+ from datetime import datetime, timedelta, timezone
41
+ from pathlib import Path
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
+ from _utils import TOPICS, RELEVANCE_NAMES # noqa: E402
45
+ from jev_client import create_client # noqa: E402
46
+
47
+ TZ = timezone(timedelta(hours=8))
48
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
+ DAILY_ROOT = os.path.join(ROOT, 'output', 'biz-daily')
50
+ LABEL_DIR = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'labels')
51
+ # 按文章路径(`id`)缓存的判断结果。见 `load_score_cache`:没有它,`--seed` 定不住样本。
52
+ SCORE_CACHE = os.path.join(LABEL_DIR, '.jev-scores.json')
53
+
54
+ WORKERS = 6
55
+ EXCERPT = 400
56
+ # 校准时按概率分桶。桶要窄到能看出斜率,又不能窄到每桶只剩两三条。
57
+ BUCKETS = [(0.0, 0.2), (0.2, 0.35), (0.35, 0.5), (0.5, 0.65), (0.65, 0.8), (0.8, 1.01)]
58
+
59
+ # 「相关度有多高」这两题的切点。**现在的值**不是真理:`jev_client.score_to_relevance`
60
+ # 用 `int(score + 0.5)` 取最近档,等价于 0.5 / 1.5 两条线,而当时写下它们时没有任何金标准
61
+ # (那句注释自己写着"阈值是暂定的")。下面的扫描就是来回答"它们该在哪"的。
62
+ CURRENT_RELEVANCE_CUTS = (0.5, 1.5)
63
+
64
+ # 扫描的候选切点。步长 0.25:原始分实测落在 0~2 之间,再细就没有足够的样本支撑。
65
+ CUT_CANDIDATES = [round(0.25 * step, 2) for step in range(1, 8)]
66
+
67
+ # 两题互相打架的判据(jev-chat-jarvis 的题目书里那条"题目之间不许互相矛盾")。
68
+ # 我们问了两道本该一致的问题:`relevance`(对读者有多大用)与 `worth_including`
69
+ # (有没有今天就能用上的具体内容)。**高相关却说没内容、低相关却说很有内容**,
70
+ # 就是同一件事被答成了两种。
71
+ CONTRADICTION_HIGH_USELESS = (1.5, 0.5) # 相关度落到「高」而收录分 < 0.5
72
+ CONTRADICTION_LOW_USEFUL = (0.5, 0.8) # 相关度落到「低」而收录分 ≥ 0.8
73
+ # 主题置信度低于这个值:不是矛盾,但"它其实也没把握"这件事要单独看见。
74
+ LOW_TOPIC_CONFIDENCE = 0.5
75
+
76
+
77
+ def load_score_cache():
78
+ """<-> 文章路径 → 那份判断。**为什么要缓存**:`--seed` 只能定住"抽哪几篇",定不住分数——
79
+ 分数由模型现场给出,同一篇两次可能 0.49 / 0.52 地跨过一个档位边界,于是**分档抽样
80
+ 抽出来的样本又变了**(实测:同一条命令连跑两次,50 条里有 7 个位置不一样)。把分数
81
+ 按文章路径缓存住,样本才真的可复现;顺带重跑不再花额度。
82
+
83
+ 代价是分数会旧:模型换了、判据改了都要 `--refresh`。
84
+ """
85
+ try:
86
+ with open(SCORE_CACHE, encoding='utf-8') as handle:
87
+ data = json.load(handle)
88
+ return data if isinstance(data, dict) else {}
89
+ except (OSError, json.JSONDecodeError):
90
+ return {}
91
+
92
+
93
+ def save_score_cache(cache):
94
+ try:
95
+ os.makedirs(LABEL_DIR, exist_ok=True)
96
+ tmp = SCORE_CACHE + '.tmp'
97
+ with open(tmp, 'w', encoding='utf-8') as handle:
98
+ json.dump(cache, handle, ensure_ascii=False)
99
+ os.replace(tmp, SCORE_CACHE)
100
+ except OSError:
101
+ pass # 缓存写不进去不该让抽样失败
102
+
103
+
104
+ def read_article(path):
105
+ """-> (frontmatter, body) 或 None(不是一篇真文章)。"""
106
+ try:
107
+ text = Path(path).read_text(encoding='utf-8', errors='replace')
108
+ except OSError:
109
+ return None
110
+ if not text.startswith('---'):
111
+ return None
112
+ parts = text.split('---', 2)
113
+ if len(parts) < 3:
114
+ return None
115
+ meta = {}
116
+ for line in parts[1].splitlines():
117
+ if ':' in line:
118
+ key, _, value = line.partition(':')
119
+ meta[key.strip()] = value.strip().strip('"')
120
+ # 日报目录里还躺着 README.md、行动建议.md 这类产物,它们没有 url。
121
+ if not meta.get('url'):
122
+ return None
123
+ return meta, parts[2].strip()
124
+
125
+
126
+ # 正文里残留的清洗痕迹。抓回来的原文在标题下有这几句固定的话,且**变体不止一种**
127
+ # (实测见过「在小说阅读器读本章」「在小说阅读器中沉浸阅读」「在公众号小说中沉浸阅读」),
128
+ # 所以按不变量匹配,别按整句。
129
+ AD_NOISE = ('在小说阅读器', '沉浸阅读')
130
+
131
+
132
+ def _after_boilerplate(lines):
133
+ """跳过开头的 `# 标题` / `> 来源…` / `---` / 空行。"""
134
+ index = 0
135
+ while index < len(lines):
136
+ line = lines[index].strip()
137
+ if not line or line.startswith('#') or line.startswith('>') or line.startswith('---'):
138
+ index += 1
139
+ continue
140
+ break
141
+ return lines[index:]
142
+
143
+
144
+ def _section(lines, heading):
145
+ """某个 `## 标题` 之下、下一个 `## ` 之前的那几行;没有这一段回 None。"""
146
+ start = None
147
+ for index, line in enumerate(lines):
148
+ if line.strip() == heading:
149
+ start = index + 1
150
+ break
151
+ if start is None:
152
+ return None
153
+ body = []
154
+ for line in lines[start:]:
155
+ if line.strip().startswith('## '):
156
+ break
157
+ body.append(line)
158
+ return _after_boilerplate(body) or None
159
+
160
+
161
+ def excerpt_of(body, limit=EXCERPT):
162
+ """取标注时唯一看得到的那 400 字。
163
+
164
+ 日报正文的固定形状是:`# 标题` + `> 来源 / > 时间 / > 原文:[阅读原文](…)` 样板 +
165
+ `## AI 摘要` + 那两三句摘要 + `## 正文` + 抓回来的原文(原文里**又**带一份标题和样板,
166
+ 还夹着"在小说阅读器读本章"这类残留)。直接取前 400 字的话,实测 50 条里多数只露出
167
+ 标题加来源,标注者(我自己试过一遍)只能靠标题猜。
168
+
169
+ 所以取**摘要段**:那两三句是判断主题与相关度最密集的信号。没有摘要(`--no-summary`
170
+ 的产物)就退回 `## 正文` 之后,再没有就退回跳过样板的第一段。
171
+ """
172
+ lines = (body or '').splitlines()
173
+ picked = (_section(lines, '## AI 摘要')
174
+ or _section(lines, '## 正文')
175
+ or _after_boilerplate(lines))
176
+ text = ' '.join(' '.join(picked).split())
177
+ for noise in AD_NOISE:
178
+ text = text.replace(noise, ' ')
179
+ return ' '.join(text.split())[:limit]
180
+
181
+
182
+ def collect_pool(days):
183
+ """最近 `days` 天里的真文章,按主题分桶(好让样本不被某一类刷屏)。"""
184
+ cutoff = (datetime.now(TZ) - timedelta(days=days)).strftime('%Y-%m-%d')
185
+ buckets = defaultdict(list)
186
+ for path in sorted(glob.glob(os.path.join(DAILY_ROOT, '*', '*', '*.md'))):
187
+ day = path.replace('\\', '/').split('/')[-3]
188
+ if day < cutoff:
189
+ continue
190
+ parsed = read_article(path)
191
+ if not parsed:
192
+ continue
193
+ meta, body = parsed
194
+ if len(body) < 200: # 太短的正文判不出什么
195
+ continue
196
+ topic = meta.get('topic') if meta.get('topic') in TOPICS else '未分类'
197
+ buckets[topic].append({
198
+ 'id': os.path.relpath(path, DAILY_ROOT).replace('\\', '/'),
199
+ 'day': day,
200
+ 'title': meta.get('title', ''),
201
+ 'storedTopic': meta.get('topic', ''),
202
+ 'storedRelevance': meta.get('relevance', ''),
203
+ 'body': body,
204
+ })
205
+ return buckets
206
+
207
+
208
+ def band_of(score):
209
+ """收录分落在哪个档。没有分数返回 None。"""
210
+ if score is None:
211
+ return None
212
+ for low, high in BUCKETS:
213
+ if low <= score < high:
214
+ return (low, high)
215
+ return None
216
+
217
+
218
+ def draw_by_band(items, n, seed):
219
+ """按**概率档位**分层取样。
220
+
221
+ 只按主题取样的样本会出现这种形状:49 篇里 45 篇的收录分都在 0.2 以下,真正处在
222
+ 决策边界上的只有 4 篇。拿这样的样本去标,校准曲线只有 4 个点,阈值扫描被一堆
223
+ 容易的负例主导——**标了 49 条,买到的信息量等于标了 4 条**。
224
+
225
+ 所以先按概率分档,再在档内轮转取。代价是样本不再是自然分布,所以**档位在全池里
226
+ 的占比要单独报出来**(`prevalence`)——它回答另一个问题:"这个阈值每天会影响
227
+ 多少篇文章"。
228
+ """
229
+ rng = random.Random(seed)
230
+ # **先按稳定键排序**。`items` 是按并发**完成**顺序追加的(`as_completed`),完成顺序每次
231
+ # 网络抖动都不一样,而不排序的话 `rng.shuffle` 吃到的就是那个顺序——同一个 seed 抽出来的
232
+ # 50 篇每次都不是同一批,而 CLI 上写着"两次抽样结果一致"。实测就是这么发现的:两次生成,
233
+ # 第 20 条一次是这篇、一次是那篇。排序之后 seed 才真的定住样本。
234
+ # 三级键:`id`(文章路径)唯一,正常情况下它就定了序;缺 `id` 的对象退到 day+title,
235
+ # 仍然是确定的——只按 `id` 排的话,没有这个字段的输入会让排序退化成空操作、确定性
236
+ # 又悄悄丢了(写这条时被自己的测试抓到过一次)。
237
+ ordered = sorted(items, key=lambda item: (str(item.get('id') or ''),
238
+ str(item.get('day') or ''),
239
+ str(item.get('title') or '')))
240
+ groups = defaultdict(list)
241
+ for item in ordered:
242
+ groups[band_of(_num((item.get('jev') or {}).get('includeScore')))].append(item)
243
+ for group in groups.values():
244
+ rng.shuffle(group)
245
+
246
+ picked, index = [], 0
247
+ # 每轮从每个档位各取一条。空档位(分数从不落在那里)自然跳过。
248
+ while len(picked) < n:
249
+ took = False
250
+ for band in sorted(groups, key=lambda b: (b is None, b)):
251
+ group = groups[band]
252
+ if index < len(group):
253
+ picked.append(group[index])
254
+ took = True
255
+ if len(picked) >= n:
256
+ break
257
+ if not took:
258
+ break
259
+ index += 1
260
+ return picked
261
+
262
+
263
+ def draw_sample(buckets, n, seed):
264
+ """轮转取样:逐轮从每个主题各取一条,样本不被某一天某一类刷屏。
265
+
266
+ 固定 seed,所以同一条命令两次跑抽到的是同一批文章——否则"我标了一半"之后
267
+ 重跑一次就全错位了。
268
+ """
269
+ rng = random.Random(seed)
270
+ pools = {topic: rng.sample(items, len(items)) for topic, items in buckets.items()}
271
+ picked, index = [], 0
272
+ while len(picked) < n:
273
+ took = False
274
+ for topic in sorted(pools):
275
+ if index < len(pools[topic]):
276
+ picked.append(pools[topic][index])
277
+ took = True
278
+ if len(picked) >= n:
279
+ break
280
+ if not took:
281
+ break
282
+ index += 1
283
+ return picked
284
+
285
+
286
+ def cmd_sample(args):
287
+ client = create_client()
288
+ if client is None:
289
+ print(json.dumps({'success': False, 'code': 'NO_KEY',
290
+ 'error': '缺少 TypeSafe key:weflow-cli config set typesafeApiKey "..."'},
291
+ ensure_ascii=False))
292
+ return 2
293
+ buckets = collect_pool(args.days)
294
+ if not buckets:
295
+ print(json.dumps({'success': False, 'code': 'NO_ARTICLES',
296
+ 'error': '最近 %d 天没有可抽样的文章(先跑日报)' % args.days},
297
+ ensure_ascii=False))
298
+ return 2
299
+ # 先给一个**比最终样本大得多**的候选池打分:分档取样需要每个档位都有候选,
300
+ # 而低分档占绝大多数,只打 n 篇的话边界档位往往只有个位数。
301
+ picked = draw_sample(buckets, args.pool, args.seed)
302
+ cache = {} if args.refresh else load_score_cache()
303
+ cached_hits = sum(1 for item in picked if item.get('id') in cache)
304
+ print('候选池 %d 篇(seed=%d),用生产路径逐篇打分…(%d 篇命中分数缓存)'
305
+ % (len(picked), args.seed, cached_hits))
306
+
307
+ started = time.time()
308
+ scored, failed = [], 0
309
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
310
+ futures = {}
311
+ for item in picked:
312
+ hit = cache.get(item.get('id') or '')
313
+ if hit:
314
+ futures[pool.submit(lambda verdict=hit: verdict)] = item
315
+ else:
316
+ futures[pool.submit(client.decide_article, item['title'], item['body'], TOPICS)] = item
317
+ for future in as_completed(futures):
318
+ item = futures[future]
319
+ try:
320
+ verdict = future.result()
321
+ except Exception as error:
322
+ failed += 1
323
+ print(' [WARN] %s 打分失败(%s)' % (item['title'][:24], type(error).__name__))
324
+ continue
325
+ item['jev'] = {key: verdict.get(key) for key in
326
+ ('topic', 'topicConfidence', 'relevance',
327
+ 'relevanceScore', 'includeScore')}
328
+ if item.get('id'):
329
+ cache[item['id']] = item['jev']
330
+ item['excerpt'] = excerpt_of(item.pop('body'))
331
+ # 三栏。`relevance` 之前没在标注范围内,于是"相关度阈值 0.5/1.5 对不对"这个
332
+ # 问题一直没有数据可答——而它现在会决定一篇文章进不进日报。
333
+ item['label'] = {'topic': None, 'relevance': None, 'include': None}
334
+ scored.append(item)
335
+ save_score_cache(cache)
336
+ print('打分完成 %d/%d,耗时 %.1fs' % (len(scored), len(picked), time.time() - started))
337
+
338
+ # 全池的档位占比:这是**另一个问题**的答案——"阈值一动,每天会多收或少收多少篇"。
339
+ prevalence = defaultdict(int)
340
+ for item in scored:
341
+ prevalence[band_of(_num(item['jev']['includeScore']))] += 1
342
+ total_pool = max(1, len(scored))
343
+
344
+ picked = draw_by_band(scored, args.n, args.seed)
345
+ by_band = defaultdict(int)
346
+ for item in picked:
347
+ by_band[band_of(_num(item['jev']['includeScore']))] += 1
348
+ print('按档位取样 %d 篇:%s' % (
349
+ len(picked), ' · '.join('%.2f-%.2f 取 %d 篇' % (b[0], b[1], c) for b, c in
350
+ sorted(by_band.items(), key=lambda kv: (kv[0] is None, kv[0])))))
351
+
352
+ os.makedirs(LABEL_DIR, exist_ok=True)
353
+ stamp = datetime.now(TZ).strftime('%Y%m%d-%H%M%S')
354
+ target = os.path.join(LABEL_DIR, 'labels-%s.json' % stamp)
355
+ payload = {
356
+ 'meta': {'createdAt': datetime.now(TZ).isoformat(timespec='seconds'),
357
+ 'seed': args.seed, 'days': args.days, 'count': len(scored),
358
+ 'poolSize': total_pool,
359
+ # 样本按档位分层,所以它不是自然分布;档位占比必须单独给,
360
+ # 否则"样本里 20% 该收"会被误读成"每天有 20% 的文章该收"。
361
+ 'prevalence': {'%.2f-%.2f' % band: round(count / total_pool, 3)
362
+ for band, count in prevalence.items() if band},
363
+ 'howTo': ('把每条的 label 填上:{"topic": "AI|学术|新闻|文学|投资|政治",'
364
+ ' "relevance": "低|中|高", "include": true|false};'
365
+ '没把握的留 null(算未标注,不算标错)。'
366
+ '填完运行:python scripts/quality_eval.py score <本文件>;'
367
+ '题目之间有没有互相打架:python scripts/quality_eval.py '
368
+ 'consistency <本文件>')},
369
+ 'items': picked,
370
+ }
371
+ with open(target, 'w', encoding='utf-8') as handle:
372
+ json.dump(payload, handle, ensure_ascii=False, indent=1)
373
+
374
+ print('\n' + '=' * 64)
375
+ # **盲标**:这张清单不印 Jev 的答案。先看见它,人就会往它靠——测出来的"一致率"会
376
+ # 偏高,而偏高的量无法估计。(`topicConfidence`/`relevanceScore`/`includeScore`
377
+ # 仍然存在 JSON 里,供算账用;标注时别翻 JSON。)
378
+ for index, item in enumerate(picked, 1):
379
+ print('%2d. %s' % (index, item['title'][:60]))
380
+ print(' %s · 历史标注 %s/%s'
381
+ % (item['day'], item['storedTopic'] or '?', item['storedRelevance'] or '-'))
382
+ print('=' * 64)
383
+ print('\n标签文件:%s' % target)
384
+ print('把每条的 label 填成 {"topic": "...", "relevance": "低|中|高", "include": true|false},然后:')
385
+ print(' python scripts/quality_eval.py score "%s"' % target)
386
+ print(' python scripts/quality_eval.py consistency "%s" # 题目之间打不打架' % target)
387
+ return 0
388
+
389
+
390
+ def _fmt(value):
391
+ return '%.2f' % value if isinstance(value, (int, float)) else '—'
392
+
393
+
394
+ def _num(value):
395
+ try:
396
+ return float(value)
397
+ except (TypeError, ValueError):
398
+ return None
399
+
400
+
401
+ def _level_for(score, low, high):
402
+ """原始分 → 三个字的档位。
403
+
404
+ 与 `jev_client.score_to_relevance` 同一套规则,只是把那两条固定的线换成了变量,
405
+ 好让它们可被扫描。
406
+ """
407
+ if score < low:
408
+ return RELEVANCE_NAMES[0]
409
+ if score < high:
410
+ return RELEVANCE_NAMES[1]
411
+ return RELEVANCE_NAMES[-1]
412
+
413
+
414
+ def score_labels(items):
415
+ """→ 一份可打印的报告结构。纯函数,好测。"""
416
+ labelled = [i for i in items
417
+ if (i.get('label') or {}).get('include') is not None]
418
+ topic_labelled = [i for i in items
419
+ if (i.get('label') or {}).get('topic')]
420
+ unscored = [i for i in labelled if _num((i.get('jev') or {}).get('includeScore')) is None]
421
+
422
+ report = {'total': len(items), 'labelled': len(labelled),
423
+ 'topicLabelled': len(topic_labelled),
424
+ 'unscored': len(unscored), 'buckets': [], 'sweep': [], 'best': None,
425
+ 'topicAgreement': None, 'storedAgreement': None}
426
+
427
+ # 主题:Jev 与人工的吻合度,以及存档标签与人工的吻合度(作为对照)
428
+ if topic_labelled:
429
+ jev_ok = sum(1 for i in topic_labelled
430
+ if (i.get('jev') or {}).get('topic') == i['label']['topic'])
431
+ stored_ok = sum(1 for i in topic_labelled
432
+ if i.get('storedTopic') == i['label']['topic'])
433
+ report['topicAgreement'] = round(jev_ok / len(topic_labelled), 3)
434
+ report['storedAgreement'] = round(stored_ok / len(topic_labelled), 3)
435
+
436
+ # 相关度:三个字的一致率 **和** 原始分的偏差。
437
+ # 只比三个字的话,"整体偏高一点点"与"基本准确"看起来一模一样——而阈值该往哪挪,
438
+ # 恰恰要看的是后者的方向与幅度。
439
+ rel_labelled = [i for i in items
440
+ if (i.get('label') or {}).get('relevance') in RELEVANCE_NAMES]
441
+ report['relevanceAgreement'] = None
442
+ report['relevanceMae'] = None
443
+ report['relevanceCuts'] = None
444
+ report['bestRelevanceCuts'] = None
445
+ report['relevanceLabelled'] = len(rel_labelled)
446
+ if rel_labelled:
447
+ jev_ok = sum(1 for i in rel_labelled
448
+ if (i.get('jev') or {}).get('relevance') == i['label']['relevance'])
449
+ report['relevanceAgreement'] = round(jev_ok / len(rel_labelled), 3)
450
+
451
+ pairs = []
452
+ for i in rel_labelled:
453
+ score = _num((i.get('jev') or {}).get('relevanceScore'))
454
+ if score is not None:
455
+ pairs.append((i, score, RELEVANCE_NAMES.index(i['label']['relevance'])))
456
+ if pairs:
457
+ # 平均差几档。0.3 以内说明分数与人的档位对得上;上了 0.7 就是系统性错位。
458
+ report['relevanceMae'] = round(
459
+ sum(abs(score - index) for _, score, index in pairs) / len(pairs), 2)
460
+
461
+ # 切点扫描:0.5/1.5 是**当时拍的**(`jev_client` 里那句注释自己写着"暂定")。
462
+ # 这里在所有候选切点上算"按分数分档"与"你标的三档"的一致率,看它该在哪。
463
+ if len(pairs) >= 5:
464
+ sweep = []
465
+ for low in CUT_CANDIDATES:
466
+ for high in CUT_CANDIDATES:
467
+ if high - low < 0.5: # 至少要留得下一整档
468
+ continue
469
+ ok = sum(1 for i, score, _ in pairs
470
+ if _level_for(score, low, high) == i['label']['relevance'])
471
+ sweep.append({'low': low, 'high': high,
472
+ 'agreement': round(ok / len(pairs), 3)})
473
+ sweep.sort(key=lambda row: (-row['agreement'], row['low'], row['high']))
474
+ report['relevanceSweep'] = sweep[:5]
475
+ current_low, current_high = CURRENT_RELEVANCE_CUTS
476
+ report['relevanceCuts'] = {
477
+ 'low': current_low, 'high': current_high,
478
+ 'agreement': next((row['agreement'] for row in sweep
479
+ if row['low'] == current_low and row['high'] == current_high),
480
+ None),
481
+ }
482
+ report['bestRelevanceCuts'] = sweep[0] if sweep else None
483
+
484
+ scored = [(i, _num(i['jev']['includeScore'])) for i in labelled]
485
+ scored = [(i, s) for i, s in scored if s is not None]
486
+ if not scored:
487
+ return report
488
+
489
+ # 校准:每个概率桶里,人工说"该收"的比例。**这就是"概率有没有意义"的答案**——
490
+ # 如果 0.5~0.65 桶里也是 90% 该收,那这个分数就没有分辨力。
491
+ for low, high in BUCKETS:
492
+ inside = [i for i, s in scored if low <= s < high]
493
+ if not inside:
494
+ continue
495
+ yes = sum(1 for i in inside if i['label']['include'])
496
+ report['buckets'].append({
497
+ 'range': [low, high], 'n': len(inside),
498
+ 'observed': round(yes / len(inside), 3),
499
+ 'mid': round((low + min(high, 1.0)) / 2, 3),
500
+ })
501
+
502
+ # 阈值扫描:找出让"按分数收录"与人工判断最一致的那个切点
503
+ for threshold in [round(0.05 * k, 2) for k in range(1, 20)]:
504
+ ok = sum(1 for i, s in scored if (s >= threshold) == bool(i['label']['include']))
505
+ report['sweep'].append({'threshold': threshold, 'agreement': round(ok / len(scored), 3)})
506
+ best = max(report['sweep'], key=lambda row: (row['agreement'], -row['threshold']))
507
+ report['best'] = best
508
+ return report
509
+
510
+
511
+ def consistency(items):
512
+ """题目之间有没有互相打架 —— **不需要人工标签**。
513
+
514
+ 我们问了两个本该一致的问题:`relevance`(对读者有多大用)与 `worth_including`
515
+ (有没有今天就能用上的具体内容)。同一篇答成"高相关 / 没内容"或"低相关 / 很有内容",
516
+ 就是同一件事被答成了两种。jev-chat-jarvis 的题目书把这条写成硬要求("题目之间不许
517
+ 互相矛盾"),他们靠标注集去压;**这一项不用**——矛盾是自证的,不需要金标准。
518
+
519
+ 只在两个分数都在场时判定:缺任何一个算"判不出来",单独计数,既不算矛盾也不算一致
520
+ (把"没答"当"没矛盾"是这个仓库反复踩过的坑)。
521
+ """
522
+ usable, unscored = [], 0
523
+ for item in items:
524
+ jev = item.get('jev') or {}
525
+ score = _num(jev.get('relevanceScore'))
526
+ include = _num(jev.get('includeScore'))
527
+ if score is None or include is None:
528
+ unscored += 1
529
+ continue
530
+ usable.append((item, score, include))
531
+
532
+ high_cut, high_need = CONTRADICTION_HIGH_USELESS
533
+ low_cut, low_need = CONTRADICTION_LOW_USEFUL
534
+ conflicts, low_confidence = [], 0
535
+ for item, score, include in usable:
536
+ if score >= high_cut and include < high_need:
537
+ conflicts.append({'kind': '高相关却无内容', 'relevanceScore': score,
538
+ 'includeScore': include, 'title': item.get('title', '')})
539
+ elif score < low_cut and include >= low_need:
540
+ conflicts.append({'kind': '低相关却很有内容', 'relevanceScore': score,
541
+ 'includeScore': include, 'title': item.get('title', '')})
542
+ confidence = _num((item.get('jev') or {}).get('topicConfidence'))
543
+ if confidence is not None and confidence < LOW_TOPIC_CONFIDENCE:
544
+ low_confidence += 1
545
+
546
+ return {
547
+ 'total': len(items), 'usable': len(usable), 'unscored': unscored,
548
+ 'conflicts': len(conflicts), 'conflictRate': (round(len(conflicts) / len(usable), 3)
549
+ if usable else None),
550
+ 'highButUseless': sum(1 for c in conflicts if c['kind'] == '高相关却无内容'),
551
+ 'lowButUseful': sum(1 for c in conflicts if c['kind'] == '低相关却很有内容'),
552
+ 'lowTopicConfidence': low_confidence,
553
+ 'examples': conflicts[:10],
554
+ }
555
+
556
+
557
+ def cmd_consistency(args):
558
+ path = args.file
559
+ if path != '-' and not os.path.isabs(path):
560
+ path = os.path.abspath(os.path.expanduser(path))
561
+ try:
562
+ with open(path, encoding='utf-8') as handle:
563
+ payload = json.load(handle)
564
+ except (OSError, json.JSONDecodeError) as error:
565
+ print(json.dumps({'success': False, 'code': 'BAD_FILE', 'error': str(error)},
566
+ ensure_ascii=False))
567
+ return 2
568
+
569
+ report = consistency(payload.get('items') or [])
570
+ if args.json:
571
+ print(json.dumps({'success': True, 'report': report}, ensure_ascii=False))
572
+ return 0
573
+
574
+ print('=' * 64)
575
+ print('题目之间的一致性(不需要人工标签):')
576
+ print(' 参与判定 %d 篇(另有 %d 篇有一题没给分,既不算矛盾也不算一致)'
577
+ % (report['usable'], report['unscored']))
578
+ if not report['usable']:
579
+ print('\n没有可判定的数据。先跑 sample 生成一份带 jev 打分的文件。')
580
+ return 0
581
+ rate = report['conflictRate']
582
+ print(' 互相打架 %d 篇(%.1f%%)' % (report['conflicts'], (rate or 0) * 100))
583
+ print(' · 相关度说「高」、收录分却说没内容:%d' % report['highButUseless'])
584
+ print(' · 相关度说「低」、收录分却说很有内容:%d' % report['lowButUseful'])
585
+ print(' 主题置信度 < %.1f 的:%d 篇(不是矛盾,是"它其实也没把握")'
586
+ % (LOW_TOPIC_CONFIDENCE, report['lowTopicConfidence']))
587
+ if report['examples']:
588
+ print('\n 前几条(完整清单用 --json 取):')
589
+ for row in report['examples']:
590
+ print(' %.2f / %.2f %s %s'
591
+ % (row['relevanceScore'], row['includeScore'], row['kind'],
592
+ row['title'][:44]))
593
+ print('\n' + '=' * 64)
594
+ print('矛盾率高说明**题目措辞或切点**有问题,不是模型有问题:同一件事被问成了两件。')
595
+ print('这一项不需要金标准,所以它现在就能给你一个数;要判断"哪个答案对"才需要标注。')
596
+ return 0
597
+
598
+
599
+ def cmd_score(args):
600
+ path = args.file
601
+ if path != '-' and not os.path.isabs(path):
602
+ path = os.path.abspath(os.path.expanduser(path))
603
+ try:
604
+ with open(path, encoding='utf-8') as handle:
605
+ payload = json.load(handle)
606
+ except (OSError, json.JSONDecodeError) as error:
607
+ print(json.dumps({'success': False, 'code': 'BAD_FILE', 'error': str(error)},
608
+ ensure_ascii=False))
609
+ return 2
610
+
611
+ items = payload.get('items') or []
612
+ report = score_labels(items)
613
+ if args.json:
614
+ print(json.dumps({'success': True, 'report': report}, ensure_ascii=False))
615
+ return 0
616
+
617
+ print('=' * 64)
618
+ print('样本 %d 篇 · 已标注 %d 篇 · 参与计算 %d 篇'
619
+ % (report['total'], report['labelled'],
620
+ sum(b['n'] for b in report['buckets'])))
621
+ if report['unscored']:
622
+ print('(其中 %d 篇 Jev 没给收录分,已排除)' % report['unscored'])
623
+ if report['labelled'] < 20:
624
+ print('\n⚠️ 标注少于 20 条,下面这些数字还不稳——**它们只是参考,不是结论**。')
625
+
626
+ if report['topicAgreement'] is not None:
627
+ print('\n主题判断(对着你标的 %d 条):' % report['topicLabelled'])
628
+ print(' Jev %.1f%%' % (report['topicAgreement'] * 100))
629
+ print(' 历史存档 %.1f%% ← 对照:这就是它一直在当"基准"的那个东西'
630
+ % (report['storedAgreement'] * 100))
631
+
632
+ if report['relevanceAgreement'] is not None:
633
+ print('\n相关度(对着你标的 %d 条):' % report['relevanceLabelled'])
634
+ print(' 低/中/高 一致率 %.1f%%' % (report['relevanceAgreement'] * 100))
635
+ if report['relevanceMae'] is not None:
636
+ print(' 平均差 %.2f 档 ← 0.3 内说明分数与你的档位对得上,'
637
+ '0.7 以上就是系统性错位' % report['relevanceMae'])
638
+
639
+ if report.get('bestRelevanceCuts') and report.get('relevanceCuts'):
640
+ best, current = report['bestRelevanceCuts'], report['relevanceCuts']
641
+ print('\n相关度切点扫描(现在是 %.2f / %.2f):' % (current['low'], current['high']))
642
+ if current['agreement'] is not None:
643
+ print(' 当前切点 %.2f / %.2f → %.1f%%'
644
+ % (current['low'], current['high'], current['agreement'] * 100))
645
+ print(' 最一致 %.2f / %.2f → %.1f%%'
646
+ % (best['low'], best['high'], best['agreement'] * 100))
647
+ print(' 切点只是拟合你标的这些文章;它没在说"哪个档位更对",'
648
+ '改它之前先看样本量。')
649
+
650
+ if report['buckets']:
651
+ print('\n收录分的校准(概率说的是不是真的):')
652
+ print(' %-14s %5s %10s' % ('概率区间', '条数', '人工说该收'))
653
+ for bucket in report['buckets']:
654
+ bar = '█' * int(round(bucket['observed'] * 20))
655
+ print(' %.2f–%.2f %5d %9.0f%% %s'
656
+ % (bucket['range'][0], bucket['range'][1], bucket['n'],
657
+ bucket['observed'] * 100, bar))
658
+ print(' 斜着往上走 = 分数有意义;一条平线 = 它只是在乱猜。')
659
+
660
+ if report['best']:
661
+ print('\n阈值扫描(按分数收录 vs 你的判断):')
662
+ for row in report['sweep']:
663
+ if abs(row['threshold'] - report['best']['threshold']) < 1e-9:
664
+ print(' %.2f %.1f%% ← 最一致' % (row['threshold'], row['agreement'] * 100))
665
+ print(' 当前用的是 0.50。上面的最优点如果离它很远,说明该改的是那个常数,'
666
+ '而不是模型。')
667
+
668
+ print('\n' + '=' * 64)
669
+ print('这些数字只代表你标的这些文章,且标注本身也有主观性。')
670
+ print('但它比"与另一个不完美的基准比"结实——那一个从来不是准确率。')
671
+ return 0
672
+
673
+
674
+ def main():
675
+ parser = argparse.ArgumentParser(description='定标:抽样、人工标注、算校准曲线与最优阈值')
676
+ sub = parser.add_subparsers(dest='command')
677
+
678
+ sample = sub.add_parser('sample', help='抽样并用生产路径打分,导出待标注清单')
679
+ sample.add_argument('--n', type=int, default=50, help='最终要标注多少篇')
680
+ sample.add_argument('--pool', type=int, default=150,
681
+ help='先给多大的候选池打分,再按概率档位取 n 篇(默认 150)')
682
+ sample.add_argument('--days', type=int, default=90, help='只从最近多少天的文章里抽')
683
+ sample.add_argument('--seed', type=int, default=20260921, help='固定种子:两次抽样结果一致')
684
+ sample.add_argument('--refresh', action='store_true',
685
+ help='忽略分数缓存重新打分(模型换了或判据改了才需要)')
686
+ sample.add_argument('--json', action='store_true')
687
+ sample.set_defaults(func=cmd_sample)
688
+
689
+ score = sub.add_parser('score', help='读回标注文件,算准确率、校准与最优阈值')
690
+ score.add_argument('file')
691
+ score.add_argument('--json', action='store_true')
692
+ score.set_defaults(func=cmd_score)
693
+
694
+ check = sub.add_parser('consistency', help='题目之间有没有互相打架(不需要人工标签)')
695
+ check.add_argument('file', help='sample 生成的标注文件(用它的 jev 打分,不再调模型)')
696
+ check.add_argument('--json', action='store_true')
697
+ check.set_defaults(func=cmd_consistency)
698
+
699
+ args = parser.parse_args()
700
+ if not args.command:
701
+ parser.print_help()
702
+ return 1
703
+ return args.func(args)
704
+
705
+
706
+ if __name__ == '__main__':
707
+ sys.exit(main())