dsh-plugin-xujie-memory 0.5.0 → 0.6.0
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.
- package/CHANGELOG.md +28 -1
- package/LICENSE +21 -21
- package/README.md +33 -8
- package/README_EN.md +27 -4
- package/cordis.patch.yml +49 -49
- package/lib/index.js +121 -0
- package/package.json +1 -1
- package/python/specs/characters/aloof.json +14 -14
- package/python/specs/characters/gentle.json +14 -14
- package/python/specs/characters/tsundere.json +14 -14
- package/python/specs/debt.v1.json +31 -31
- package/python/specs/nickname.v1.json +16 -16
- package/python/specs/preference.v1.json +16 -16
- package/python/xujie_memory/admission.py +211 -211
- package/python/xujie_memory/audit.py +377 -377
- package/python/xujie_memory/benchmark.py +281 -281
- package/python/xujie_memory/character.py +199 -199
- package/python/xujie_memory/console.py +41 -41
- package/python/xujie_memory/consolidate.py +356 -356
- package/python/xujie_memory/due.py +121 -121
- package/python/xujie_memory/emotion.py +573 -573
- package/python/xujie_memory/errors.py +98 -98
- package/python/xujie_memory/importance.py +104 -104
- package/python/xujie_memory/judge.py +350 -350
- package/python/xujie_memory/memory.py +55 -0
- package/python/xujie_memory/orchestrator.py +690 -666
- package/python/xujie_memory/phrasing.py +292 -292
- package/python/xujie_memory/server.py +24 -0
- package/python/xujie_memory/specs.py +170 -170
- package/python/xujie_memory/summary.py +238 -238
|
@@ -1,281 +1,281 @@
|
|
|
1
|
-
"""稳定的编码器基准(XM-02 / XM-13)。
|
|
2
|
-
|
|
3
|
-
回答两个问题,只报数据不下结论:
|
|
4
|
-
|
|
5
|
-
1. **现在的编码器到底多少分**:固定语料、固定匹配规则、可复现的 JSON。
|
|
6
|
-
不报"感觉好多了",改一行代码就重跑、和 baseline 比 delta。
|
|
7
|
-
2. **错在哪**:按语言现象 / 语料分组给分,并列出具体错句,避免平均值掩盖局部灾难。
|
|
8
|
-
|
|
9
|
-
为什么是「可复现」:报告里带语料 hash、预测 hash、匹配规则版本;同一份输入
|
|
10
|
-
两次运行必须得到同一个预测 hash(determinism 字段)。规则编码器本身是确定的,
|
|
11
|
-
LLM 编码器不保证——所以基准默认只跑 RuleEncoder,LLM 的稳定性另用 evaluate.py 的
|
|
12
|
-
--repeat 观测。
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import hashlib
|
|
18
|
-
import json
|
|
19
|
-
from collections import Counter, defaultdict
|
|
20
|
-
from dataclasses import dataclass
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
from typing import Any, Iterable
|
|
23
|
-
|
|
24
|
-
from .encoder import RuleEncoder
|
|
25
|
-
|
|
26
|
-
BENCHMARK_VERSION = 1
|
|
27
|
-
SLOT_KEYS = ("debtor", "creditor", "item", "quantity", "unit")
|
|
28
|
-
# 编码器用 self/player 两种写法,标注统一成 player。
|
|
29
|
-
SLOT_ALIASES = {"self": "player", "me": "player", "我": "player", "你": "kitty"}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _canon_value(key: str, value: Any) -> Any:
|
|
33
|
-
if isinstance(value, str):
|
|
34
|
-
value = value.strip()
|
|
35
|
-
if key in ("debtor", "creditor"):
|
|
36
|
-
value = SLOT_ALIASES.get(value, value)
|
|
37
|
-
return value
|
|
38
|
-
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
39
|
-
return round(float(value), 6)
|
|
40
|
-
return value
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def canon_slots(slots: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
-
return {k: _canon_value(k, v) for k, v in slots.items() if v is not None}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def gold_intent(interp: dict[str, Any]) -> str | None:
|
|
48
|
-
slots = interp.get("slots") or {}
|
|
49
|
-
return interp.get("intent") or slots.get("intent") or interp.get("semantic_op")
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _pred_dict(pred) -> dict[str, Any]:
|
|
53
|
-
return {
|
|
54
|
-
"relation": pred.relation,
|
|
55
|
-
"intent": pred.semantic_op,
|
|
56
|
-
"slots": canon_slots(pred.slots or {}),
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def score_item(item: dict[str, Any], preds: list[Any]) -> dict[str, Any]:
|
|
61
|
-
golds = [
|
|
62
|
-
{
|
|
63
|
-
"relation": g.get("relation"),
|
|
64
|
-
"intent": gold_intent(g),
|
|
65
|
-
"slots": canon_slots(g.get("slots") or {}),
|
|
66
|
-
}
|
|
67
|
-
for g in item.get("interpretations") or []
|
|
68
|
-
]
|
|
69
|
-
predicted = [_pred_dict(p) for p in preds]
|
|
70
|
-
gold_relations = {g["relation"] for g in golds}
|
|
71
|
-
pred_relations = {p["relation"] for p in predicted}
|
|
72
|
-
relation_hit = bool(gold_relations & pred_relations)
|
|
73
|
-
false_positive = sorted(pred_relations - gold_relations)
|
|
74
|
-
|
|
75
|
-
# 无关句(gold 没有 interpretation):预测为空才算对;
|
|
76
|
-
# 任何预测都是误报。以前把它算成"永远错",会低估编码器。
|
|
77
|
-
if not golds:
|
|
78
|
-
empty = not predicted
|
|
79
|
-
return {
|
|
80
|
-
"id": item.get("id"),
|
|
81
|
-
"lang": item.get("lang"),
|
|
82
|
-
"utterance": item.get("utterance"),
|
|
83
|
-
"gold": golds,
|
|
84
|
-
"predicted": predicted,
|
|
85
|
-
"relation_hit": empty,
|
|
86
|
-
"intent_hit": empty,
|
|
87
|
-
"exact": empty,
|
|
88
|
-
"slot_matches": {},
|
|
89
|
-
"false_positive": false_positive,
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
best: dict[str, Any] | None = None
|
|
93
|
-
best_score = -1
|
|
94
|
-
best_detail: dict[str, Any] = {}
|
|
95
|
-
for gold in golds:
|
|
96
|
-
for pred in predicted:
|
|
97
|
-
slot_matches = {}
|
|
98
|
-
for key in SLOT_KEYS:
|
|
99
|
-
if key not in gold["slots"]:
|
|
100
|
-
continue
|
|
101
|
-
slot_matches[key] = pred["slots"].get(key) == gold["slots"].get(key)
|
|
102
|
-
score = (
|
|
103
|
-
(2 if pred["relation"] == gold["relation"] else 0)
|
|
104
|
-
+ (1 if pred["relation"] == gold["relation"] and pred["intent"] == gold["intent"] else 0)
|
|
105
|
-
+ sum(1 for ok in slot_matches.values() if ok)
|
|
106
|
-
)
|
|
107
|
-
if score > best_score:
|
|
108
|
-
best_score = score
|
|
109
|
-
best = pred
|
|
110
|
-
best_detail = {"gold": gold, "slot_matches": slot_matches}
|
|
111
|
-
if best is None:
|
|
112
|
-
return {
|
|
113
|
-
"id": item.get("id"),
|
|
114
|
-
"lang": item.get("lang"),
|
|
115
|
-
"utterance": item.get("utterance"),
|
|
116
|
-
"gold": golds,
|
|
117
|
-
"predicted": predicted,
|
|
118
|
-
"relation_hit": False,
|
|
119
|
-
"intent_hit": False,
|
|
120
|
-
"exact": False,
|
|
121
|
-
"slot_matches": {},
|
|
122
|
-
"false_positive": false_positive,
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
gold = best_detail["gold"]
|
|
126
|
-
intent_hit = best["relation"] == gold["relation"] and best["intent"] == gold["intent"]
|
|
127
|
-
exact = intent_hit and all(best_detail["slot_matches"].values())
|
|
128
|
-
return {
|
|
129
|
-
"id": item.get("id"),
|
|
130
|
-
"lang": item.get("lang"),
|
|
131
|
-
"utterance": item.get("utterance"),
|
|
132
|
-
"gold": golds,
|
|
133
|
-
"predicted": predicted,
|
|
134
|
-
"relation_hit": best["relation"] == gold["relation"],
|
|
135
|
-
"intent_hit": intent_hit,
|
|
136
|
-
"exact": exact,
|
|
137
|
-
"slot_matches": best_detail["slot_matches"],
|
|
138
|
-
"false_positive": false_positive,
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def aggregate(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
143
|
-
n = len(rows)
|
|
144
|
-
slot_totals: Counter[str] = Counter()
|
|
145
|
-
slot_hits: Counter[str] = Counter()
|
|
146
|
-
for row in rows:
|
|
147
|
-
for key, ok in row["slot_matches"].items():
|
|
148
|
-
slot_totals[key] += 1
|
|
149
|
-
slot_hits[key] += 1 if ok else 0
|
|
150
|
-
by_lang: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
151
|
-
by_family: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
152
|
-
for row in rows:
|
|
153
|
-
label = str(row.get("lang") or "未分组")
|
|
154
|
-
by_lang[label].append(row)
|
|
155
|
-
# 「断言-完整」「断言-已确认」归到「断言」,避免 1 条一组看不出趋势。
|
|
156
|
-
by_family[label.split("-")[0]].append(row)
|
|
157
|
-
|
|
158
|
-
def rate(part: int, total: int) -> float | None:
|
|
159
|
-
return round(part / total, 4) if total else None
|
|
160
|
-
|
|
161
|
-
summary = {
|
|
162
|
-
"n": n,
|
|
163
|
-
"coverage": rate(sum(1 for r in rows if r["predicted"]), n),
|
|
164
|
-
"relation_accuracy": rate(sum(1 for r in rows if r["relation_hit"]), n),
|
|
165
|
-
"intent_accuracy": rate(sum(1 for r in rows if r["intent_hit"]), n),
|
|
166
|
-
"exact_match": rate(sum(1 for r in rows if r["exact"]), n),
|
|
167
|
-
"false_positive_rate": rate(sum(1 for r in rows if r["false_positive"]), n),
|
|
168
|
-
"slot_accuracy": {
|
|
169
|
-
key: rate(slot_hits[key], slot_totals[key]) for key in SLOT_KEYS
|
|
170
|
-
},
|
|
171
|
-
}
|
|
172
|
-
return {"summary": summary, "by_family": _grouped(by_family, rate), "by_lang": _grouped(by_lang, rate)}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _grouped(groups: dict[str, list[dict[str, Any]]], rate) -> dict[str, dict[str, Any]]:
|
|
176
|
-
out = {}
|
|
177
|
-
for name, group in sorted(groups.items()):
|
|
178
|
-
out[name] = {
|
|
179
|
-
"n": len(group),
|
|
180
|
-
"relation_accuracy": rate(sum(1 for r in group if r["relation_hit"]), len(group)),
|
|
181
|
-
"intent_accuracy": rate(sum(1 for r in group if r["intent_hit"]), len(group)),
|
|
182
|
-
"exact_match": rate(sum(1 for r in group if r["exact"]), len(group)),
|
|
183
|
-
}
|
|
184
|
-
return out
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _canonical(obj: Any) -> str:
|
|
188
|
-
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def _sha(text: str) -> str:
|
|
192
|
-
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def load_corpus(path: str | Path) -> list[dict[str, Any]]:
|
|
196
|
-
items = []
|
|
197
|
-
with Path(path).open("r", encoding="utf-8") as fh:
|
|
198
|
-
for line in fh:
|
|
199
|
-
line = line.strip()
|
|
200
|
-
if line:
|
|
201
|
-
items.append(json.loads(line))
|
|
202
|
-
return items
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def run(corpus_path: str | Path, encoder=None, *, limit: int | None = None) -> dict[str, Any]:
|
|
206
|
-
"""跑一遍基准,返回可复现的报告(不含时间戳)。"""
|
|
207
|
-
items = load_corpus(corpus_path)
|
|
208
|
-
if limit is not None:
|
|
209
|
-
items = items[:limit]
|
|
210
|
-
encoder = encoder or RuleEncoder()
|
|
211
|
-
rows = []
|
|
212
|
-
for item in items:
|
|
213
|
-
preds = encoder.encode(str(item.get("id")), item.get("utterance") or "", dict(item.get("context") or {}))
|
|
214
|
-
rows.append(score_item(item, preds))
|
|
215
|
-
|
|
216
|
-
predictions = [
|
|
217
|
-
{"id": row["id"], "predicted": row["predicted"]} for row in rows
|
|
218
|
-
]
|
|
219
|
-
pred_hash = _sha(_canonical(predictions))
|
|
220
|
-
# 规则编码器是确定的,跑两遍校验;LLM 编码器不重复跑(浪费钱),
|
|
221
|
-
# 它的稳定性用 --samples / examples/evaluate.py --repeat 单独观测。
|
|
222
|
-
deterministic: bool | None = None
|
|
223
|
-
if type(encoder).__name__ == "RuleEncoder":
|
|
224
|
-
second = [
|
|
225
|
-
_pred_dict(p)
|
|
226
|
-
for item in items
|
|
227
|
-
for p in encoder.encode(str(item.get("id")), item.get("utterance") or "", dict(item.get("context") or {}))
|
|
228
|
-
]
|
|
229
|
-
deterministic = _sha(_canonical(second)) == _sha(
|
|
230
|
-
_canonical([p for row in rows for p in row["predicted"]])
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
usage = None
|
|
234
|
-
usages = getattr(encoder, "usage", None) or []
|
|
235
|
-
if usages:
|
|
236
|
-
usage = {
|
|
237
|
-
"calls": len(usages),
|
|
238
|
-
"prompt_tokens": sum(int(u.get("prompt_tokens") or 0) for u in usages),
|
|
239
|
-
"completion_tokens": sum(int(u.get("completion_tokens") or 0) for u in usages),
|
|
240
|
-
"reasoning_tokens": sum(
|
|
241
|
-
int((u.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0)
|
|
242
|
-
for u in usages
|
|
243
|
-
),
|
|
244
|
-
"total_tokens": sum(int(u.get("total_tokens") or 0) for u in usages),
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
report = {
|
|
248
|
-
"benchmark_version": BENCHMARK_VERSION,
|
|
249
|
-
"encoder": type(encoder).__name__,
|
|
250
|
-
"corpus": {
|
|
251
|
-
"path": Path(corpus_path).name,
|
|
252
|
-
"sha256": _sha(Path(corpus_path).read_text(encoding="utf-8")),
|
|
253
|
-
"n": len(items),
|
|
254
|
-
},
|
|
255
|
-
**aggregate(rows),
|
|
256
|
-
"deterministic": deterministic,
|
|
257
|
-
"predictions_sha256": pred_hash,
|
|
258
|
-
"usage": usage,
|
|
259
|
-
"errors": [row for row in rows if not row["exact"]],
|
|
260
|
-
}
|
|
261
|
-
return report
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def compare(baseline: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
|
|
265
|
-
"""只比 summary 里的标量;分组和逐句用 errors 自己看。"""
|
|
266
|
-
out = {}
|
|
267
|
-
for key, value in current["summary"].items():
|
|
268
|
-
if key == "slot_accuracy":
|
|
269
|
-
out[key] = {
|
|
270
|
-
slot: _delta(baseline["summary"]["slot_accuracy"].get(slot), value[slot])
|
|
271
|
-
for slot in value
|
|
272
|
-
}
|
|
273
|
-
else:
|
|
274
|
-
out[key] = _delta(baseline["summary"].get(key), value)
|
|
275
|
-
return out
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def _delta(old: Any, new: Any) -> dict[str, Any]:
|
|
279
|
-
if old is None or new is None:
|
|
280
|
-
return {"baseline": old, "current": new, "delta": None}
|
|
281
|
-
return {"baseline": old, "current": new, "delta": round(new - old, 4)}
|
|
1
|
+
"""稳定的编码器基准(XM-02 / XM-13)。
|
|
2
|
+
|
|
3
|
+
回答两个问题,只报数据不下结论:
|
|
4
|
+
|
|
5
|
+
1. **现在的编码器到底多少分**:固定语料、固定匹配规则、可复现的 JSON。
|
|
6
|
+
不报"感觉好多了",改一行代码就重跑、和 baseline 比 delta。
|
|
7
|
+
2. **错在哪**:按语言现象 / 语料分组给分,并列出具体错句,避免平均值掩盖局部灾难。
|
|
8
|
+
|
|
9
|
+
为什么是「可复现」:报告里带语料 hash、预测 hash、匹配规则版本;同一份输入
|
|
10
|
+
两次运行必须得到同一个预测 hash(determinism 字段)。规则编码器本身是确定的,
|
|
11
|
+
LLM 编码器不保证——所以基准默认只跑 RuleEncoder,LLM 的稳定性另用 evaluate.py 的
|
|
12
|
+
--repeat 观测。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from collections import Counter, defaultdict
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Iterable
|
|
23
|
+
|
|
24
|
+
from .encoder import RuleEncoder
|
|
25
|
+
|
|
26
|
+
BENCHMARK_VERSION = 1
|
|
27
|
+
SLOT_KEYS = ("debtor", "creditor", "item", "quantity", "unit")
|
|
28
|
+
# 编码器用 self/player 两种写法,标注统一成 player。
|
|
29
|
+
SLOT_ALIASES = {"self": "player", "me": "player", "我": "player", "你": "kitty"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _canon_value(key: str, value: Any) -> Any:
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
value = value.strip()
|
|
35
|
+
if key in ("debtor", "creditor"):
|
|
36
|
+
value = SLOT_ALIASES.get(value, value)
|
|
37
|
+
return value
|
|
38
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
39
|
+
return round(float(value), 6)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canon_slots(slots: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
return {k: _canon_value(k, v) for k, v in slots.items() if v is not None}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def gold_intent(interp: dict[str, Any]) -> str | None:
|
|
48
|
+
slots = interp.get("slots") or {}
|
|
49
|
+
return interp.get("intent") or slots.get("intent") or interp.get("semantic_op")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pred_dict(pred) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"relation": pred.relation,
|
|
55
|
+
"intent": pred.semantic_op,
|
|
56
|
+
"slots": canon_slots(pred.slots or {}),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def score_item(item: dict[str, Any], preds: list[Any]) -> dict[str, Any]:
|
|
61
|
+
golds = [
|
|
62
|
+
{
|
|
63
|
+
"relation": g.get("relation"),
|
|
64
|
+
"intent": gold_intent(g),
|
|
65
|
+
"slots": canon_slots(g.get("slots") or {}),
|
|
66
|
+
}
|
|
67
|
+
for g in item.get("interpretations") or []
|
|
68
|
+
]
|
|
69
|
+
predicted = [_pred_dict(p) for p in preds]
|
|
70
|
+
gold_relations = {g["relation"] for g in golds}
|
|
71
|
+
pred_relations = {p["relation"] for p in predicted}
|
|
72
|
+
relation_hit = bool(gold_relations & pred_relations)
|
|
73
|
+
false_positive = sorted(pred_relations - gold_relations)
|
|
74
|
+
|
|
75
|
+
# 无关句(gold 没有 interpretation):预测为空才算对;
|
|
76
|
+
# 任何预测都是误报。以前把它算成"永远错",会低估编码器。
|
|
77
|
+
if not golds:
|
|
78
|
+
empty = not predicted
|
|
79
|
+
return {
|
|
80
|
+
"id": item.get("id"),
|
|
81
|
+
"lang": item.get("lang"),
|
|
82
|
+
"utterance": item.get("utterance"),
|
|
83
|
+
"gold": golds,
|
|
84
|
+
"predicted": predicted,
|
|
85
|
+
"relation_hit": empty,
|
|
86
|
+
"intent_hit": empty,
|
|
87
|
+
"exact": empty,
|
|
88
|
+
"slot_matches": {},
|
|
89
|
+
"false_positive": false_positive,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
best: dict[str, Any] | None = None
|
|
93
|
+
best_score = -1
|
|
94
|
+
best_detail: dict[str, Any] = {}
|
|
95
|
+
for gold in golds:
|
|
96
|
+
for pred in predicted:
|
|
97
|
+
slot_matches = {}
|
|
98
|
+
for key in SLOT_KEYS:
|
|
99
|
+
if key not in gold["slots"]:
|
|
100
|
+
continue
|
|
101
|
+
slot_matches[key] = pred["slots"].get(key) == gold["slots"].get(key)
|
|
102
|
+
score = (
|
|
103
|
+
(2 if pred["relation"] == gold["relation"] else 0)
|
|
104
|
+
+ (1 if pred["relation"] == gold["relation"] and pred["intent"] == gold["intent"] else 0)
|
|
105
|
+
+ sum(1 for ok in slot_matches.values() if ok)
|
|
106
|
+
)
|
|
107
|
+
if score > best_score:
|
|
108
|
+
best_score = score
|
|
109
|
+
best = pred
|
|
110
|
+
best_detail = {"gold": gold, "slot_matches": slot_matches}
|
|
111
|
+
if best is None:
|
|
112
|
+
return {
|
|
113
|
+
"id": item.get("id"),
|
|
114
|
+
"lang": item.get("lang"),
|
|
115
|
+
"utterance": item.get("utterance"),
|
|
116
|
+
"gold": golds,
|
|
117
|
+
"predicted": predicted,
|
|
118
|
+
"relation_hit": False,
|
|
119
|
+
"intent_hit": False,
|
|
120
|
+
"exact": False,
|
|
121
|
+
"slot_matches": {},
|
|
122
|
+
"false_positive": false_positive,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
gold = best_detail["gold"]
|
|
126
|
+
intent_hit = best["relation"] == gold["relation"] and best["intent"] == gold["intent"]
|
|
127
|
+
exact = intent_hit and all(best_detail["slot_matches"].values())
|
|
128
|
+
return {
|
|
129
|
+
"id": item.get("id"),
|
|
130
|
+
"lang": item.get("lang"),
|
|
131
|
+
"utterance": item.get("utterance"),
|
|
132
|
+
"gold": golds,
|
|
133
|
+
"predicted": predicted,
|
|
134
|
+
"relation_hit": best["relation"] == gold["relation"],
|
|
135
|
+
"intent_hit": intent_hit,
|
|
136
|
+
"exact": exact,
|
|
137
|
+
"slot_matches": best_detail["slot_matches"],
|
|
138
|
+
"false_positive": false_positive,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def aggregate(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
143
|
+
n = len(rows)
|
|
144
|
+
slot_totals: Counter[str] = Counter()
|
|
145
|
+
slot_hits: Counter[str] = Counter()
|
|
146
|
+
for row in rows:
|
|
147
|
+
for key, ok in row["slot_matches"].items():
|
|
148
|
+
slot_totals[key] += 1
|
|
149
|
+
slot_hits[key] += 1 if ok else 0
|
|
150
|
+
by_lang: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
151
|
+
by_family: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
152
|
+
for row in rows:
|
|
153
|
+
label = str(row.get("lang") or "未分组")
|
|
154
|
+
by_lang[label].append(row)
|
|
155
|
+
# 「断言-完整」「断言-已确认」归到「断言」,避免 1 条一组看不出趋势。
|
|
156
|
+
by_family[label.split("-")[0]].append(row)
|
|
157
|
+
|
|
158
|
+
def rate(part: int, total: int) -> float | None:
|
|
159
|
+
return round(part / total, 4) if total else None
|
|
160
|
+
|
|
161
|
+
summary = {
|
|
162
|
+
"n": n,
|
|
163
|
+
"coverage": rate(sum(1 for r in rows if r["predicted"]), n),
|
|
164
|
+
"relation_accuracy": rate(sum(1 for r in rows if r["relation_hit"]), n),
|
|
165
|
+
"intent_accuracy": rate(sum(1 for r in rows if r["intent_hit"]), n),
|
|
166
|
+
"exact_match": rate(sum(1 for r in rows if r["exact"]), n),
|
|
167
|
+
"false_positive_rate": rate(sum(1 for r in rows if r["false_positive"]), n),
|
|
168
|
+
"slot_accuracy": {
|
|
169
|
+
key: rate(slot_hits[key], slot_totals[key]) for key in SLOT_KEYS
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
return {"summary": summary, "by_family": _grouped(by_family, rate), "by_lang": _grouped(by_lang, rate)}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _grouped(groups: dict[str, list[dict[str, Any]]], rate) -> dict[str, dict[str, Any]]:
|
|
176
|
+
out = {}
|
|
177
|
+
for name, group in sorted(groups.items()):
|
|
178
|
+
out[name] = {
|
|
179
|
+
"n": len(group),
|
|
180
|
+
"relation_accuracy": rate(sum(1 for r in group if r["relation_hit"]), len(group)),
|
|
181
|
+
"intent_accuracy": rate(sum(1 for r in group if r["intent_hit"]), len(group)),
|
|
182
|
+
"exact_match": rate(sum(1 for r in group if r["exact"]), len(group)),
|
|
183
|
+
}
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _canonical(obj: Any) -> str:
|
|
188
|
+
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sha(text: str) -> str:
|
|
192
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_corpus(path: str | Path) -> list[dict[str, Any]]:
|
|
196
|
+
items = []
|
|
197
|
+
with Path(path).open("r", encoding="utf-8") as fh:
|
|
198
|
+
for line in fh:
|
|
199
|
+
line = line.strip()
|
|
200
|
+
if line:
|
|
201
|
+
items.append(json.loads(line))
|
|
202
|
+
return items
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run(corpus_path: str | Path, encoder=None, *, limit: int | None = None) -> dict[str, Any]:
|
|
206
|
+
"""跑一遍基准,返回可复现的报告(不含时间戳)。"""
|
|
207
|
+
items = load_corpus(corpus_path)
|
|
208
|
+
if limit is not None:
|
|
209
|
+
items = items[:limit]
|
|
210
|
+
encoder = encoder or RuleEncoder()
|
|
211
|
+
rows = []
|
|
212
|
+
for item in items:
|
|
213
|
+
preds = encoder.encode(str(item.get("id")), item.get("utterance") or "", dict(item.get("context") or {}))
|
|
214
|
+
rows.append(score_item(item, preds))
|
|
215
|
+
|
|
216
|
+
predictions = [
|
|
217
|
+
{"id": row["id"], "predicted": row["predicted"]} for row in rows
|
|
218
|
+
]
|
|
219
|
+
pred_hash = _sha(_canonical(predictions))
|
|
220
|
+
# 规则编码器是确定的,跑两遍校验;LLM 编码器不重复跑(浪费钱),
|
|
221
|
+
# 它的稳定性用 --samples / examples/evaluate.py --repeat 单独观测。
|
|
222
|
+
deterministic: bool | None = None
|
|
223
|
+
if type(encoder).__name__ == "RuleEncoder":
|
|
224
|
+
second = [
|
|
225
|
+
_pred_dict(p)
|
|
226
|
+
for item in items
|
|
227
|
+
for p in encoder.encode(str(item.get("id")), item.get("utterance") or "", dict(item.get("context") or {}))
|
|
228
|
+
]
|
|
229
|
+
deterministic = _sha(_canonical(second)) == _sha(
|
|
230
|
+
_canonical([p for row in rows for p in row["predicted"]])
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
usage = None
|
|
234
|
+
usages = getattr(encoder, "usage", None) or []
|
|
235
|
+
if usages:
|
|
236
|
+
usage = {
|
|
237
|
+
"calls": len(usages),
|
|
238
|
+
"prompt_tokens": sum(int(u.get("prompt_tokens") or 0) for u in usages),
|
|
239
|
+
"completion_tokens": sum(int(u.get("completion_tokens") or 0) for u in usages),
|
|
240
|
+
"reasoning_tokens": sum(
|
|
241
|
+
int((u.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0)
|
|
242
|
+
for u in usages
|
|
243
|
+
),
|
|
244
|
+
"total_tokens": sum(int(u.get("total_tokens") or 0) for u in usages),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
report = {
|
|
248
|
+
"benchmark_version": BENCHMARK_VERSION,
|
|
249
|
+
"encoder": type(encoder).__name__,
|
|
250
|
+
"corpus": {
|
|
251
|
+
"path": Path(corpus_path).name,
|
|
252
|
+
"sha256": _sha(Path(corpus_path).read_text(encoding="utf-8")),
|
|
253
|
+
"n": len(items),
|
|
254
|
+
},
|
|
255
|
+
**aggregate(rows),
|
|
256
|
+
"deterministic": deterministic,
|
|
257
|
+
"predictions_sha256": pred_hash,
|
|
258
|
+
"usage": usage,
|
|
259
|
+
"errors": [row for row in rows if not row["exact"]],
|
|
260
|
+
}
|
|
261
|
+
return report
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def compare(baseline: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
|
|
265
|
+
"""只比 summary 里的标量;分组和逐句用 errors 自己看。"""
|
|
266
|
+
out = {}
|
|
267
|
+
for key, value in current["summary"].items():
|
|
268
|
+
if key == "slot_accuracy":
|
|
269
|
+
out[key] = {
|
|
270
|
+
slot: _delta(baseline["summary"]["slot_accuracy"].get(slot), value[slot])
|
|
271
|
+
for slot in value
|
|
272
|
+
}
|
|
273
|
+
else:
|
|
274
|
+
out[key] = _delta(baseline["summary"].get(key), value)
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _delta(old: Any, new: Any) -> dict[str, Any]:
|
|
279
|
+
if old is None or new is None:
|
|
280
|
+
return {"baseline": old, "current": new, "delta": None}
|
|
281
|
+
return {"baseline": old, "current": new, "delta": round(new - old, 4)}
|