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.
@@ -1,350 +1,350 @@
1
- """认知判断:把线索、共同回忆、画像组织成一个**暂时**结论。
2
-
3
- 方案 2.1 C 说的是三件事:
4
-
5
- 解释矛盾、评估人物一致性、调取共同回忆并形成暂时判断。
6
-
7
- 关键词是「暂时」。这一层不宣布"这个人是谁"——
8
- 它只回答"我现在更倾向哪种解释,凭什么,还差什么"。
9
-
10
- ---
11
-
12
- ## 为什么判断和授权必须分开
13
-
14
- 5.3 有一句容易被忽略但很重的话:
15
-
16
- > 回答正确不能自动开放私人信息。
17
-
18
- 意思是:即使对方答对了你们的共同经历,也不等于他是本人。
19
- 共同回忆会泄露(他自己说过、别人听过、猜也能猜个大概)。
20
- 所以「我判断他是同一个人」和「我可以把私人记忆给他看」
21
- 是**两个独立的问题**,不能用一个 verdict 同时回答。
22
-
23
- `may_share_sensitive()` 因此**不看 verdict**。它只看:
24
- 画像是不是可疑、有没有人明说过"我不是本人"。
25
- 判断错了最坏是语气不对;授权错了是不可逆的泄露。
26
-
27
- ## 为什么会有 `unknown`
28
-
29
- 因为绝大多数情况真的判断不了。
30
-
31
- 刚聊两句、对方只说了一句不像的话、你手上只有一条旧记忆——
32
- 这些都是 `unknown`,而且**这就是正确答案**。
33
- 强行给一个 same/different,只是把不确定性藏起来,
34
- 而藏起来的不确定性会在后面变成更贵的错误。
35
-
36
- 方案 5.1 也说得很清楚:主 AI「尝试解释」,不是「必须解释」。
37
-
38
- ## 这一层不生成台词
39
-
40
- 它给出的是「有什么证据、还差什么、能不能给信息」。
41
- 要不要问「你还记得那次丢钱的事吗」——那是主 AI 的事。
42
- 把试探话术写进这里,五种角色就会问出同一句话。
43
- """
44
-
45
- from __future__ import annotations
46
-
47
- from dataclasses import dataclass, field
48
-
49
- from .profile import ProfileStore
50
- from .recall import Budget, Recall
51
-
52
- # 三种结论。`unknown` 是合法答案,不是失败。
53
- SAME = "same_person"
54
- DIFFERENT = "different_person"
55
- UNKNOWN = "unknown"
56
-
57
- # 证据的方向:正数指向"是同一个人",负数指向"不是"。
58
- # 数值只用于排序与解释,不追求精确——这里没有"准确率"可谈,
59
- # 因为真正的判断在主 AI 那里。
60
- _WEIGHTS = {
61
- "shared_memory": 1.0, # 对方主动提到只属于你们的经历
62
- "answered_recall": 1.5, # 对方答对了你问的共同经历
63
- "profile_fit": 0.5, # 说的话跟画像对得上
64
- "explicit_claim": -3.0, # 明说"我不是本人"
65
- "address_shift": -1.5, # 自称的称呼变了
66
- "profile_miss": -1.0, # 说的话跟画像明显不符
67
- }
68
-
69
-
70
- @dataclass
71
- class Evidence:
72
- """一条证据。必须能追到出处,否则它只是印象。"""
73
-
74
- kind: str
75
- summary: str
76
- weight: float
77
- trace_id: str | None = None
78
-
79
- def as_dict(self) -> dict:
80
- return {
81
- "kind": self.kind,
82
- "summary": self.summary,
83
- "weight": self.weight,
84
- "trace_id": self.trace_id,
85
- }
86
-
87
-
88
- @dataclass
89
- class Assessment:
90
- """一份**暂时**判断。
91
-
92
- 没有 `is_this_person` 这种布尔字段,只有一个带置信度的倾向。
93
- 布尔会诱使下游把它当事实用。
94
- """
95
-
96
- verdict: str = UNKNOWN
97
- confidence: float = 0.0
98
- evidence: list[Evidence] = field(default_factory=list)
99
- shared_memories: list[dict] = field(default_factory=list)
100
- # 还差什么。给主 AI 决定要不要继续问,以及问哪一类。
101
- open_questions: list[str] = field(default_factory=list)
102
- # 能不能给敏感信息。**与 verdict 无关**(5.3)。
103
- may_share_sensitive: bool = False
104
- reasons: list[str] = field(default_factory=list)
105
-
106
- @property
107
- def score(self) -> float:
108
- return sum(e.weight for e in self.evidence)
109
-
110
- def as_dict(self) -> dict:
111
- return {
112
- "verdict": self.verdict,
113
- "confidence": round(self.confidence, 3),
114
- "score": round(self.score, 3),
115
- "evidence": [e.as_dict() for e in self.evidence],
116
- "shared_memories": self.shared_memories,
117
- "open_questions": self.open_questions,
118
- "may_share_sensitive": self.may_share_sensitive,
119
- "reasons": self.reasons,
120
- }
121
-
122
- def render(self) -> str:
123
- """给人看的一段话。"""
124
- names = {SAME: "像是同一个人", DIFFERENT: "不像同一个人",
125
- UNKNOWN: "判断不了"}
126
- lines = [f"倾向:{names[self.verdict]}(置信 {self.confidence:.2f})"]
127
- if self.evidence:
128
- lines.append("依据:")
129
- for e in self.evidence:
130
- mark = "+" if e.weight > 0 else "-"
131
- lines.append(f" {mark} {e.summary}")
132
- if self.shared_memories:
133
- lines.append("你们的共同经历:")
134
- for m in self.shared_memories:
135
- lines.append(f" - {m['content']}")
136
- if self.open_questions:
137
- lines.append("还差:" + "、".join(self.open_questions))
138
- lines.append(
139
- "敏感信息:" + ("可以给" if self.may_share_sensitive else "不能给")
140
- )
141
- return "\n".join(lines)
142
-
143
-
144
- class Judge:
145
- """认知判断。只出结论,不改任何状态。"""
146
-
147
- def __init__(self, engine, *, recall: Recall | None = None,
148
- profiles: ProfileStore | None = None):
149
- self.engine = engine
150
- self.recall = recall or Recall(engine)
151
- self.profiles = profiles or ProfileStore(engine)
152
-
153
- # ---------------------------------------------------------- 判断
154
- def assess(
155
- self,
156
- scope_id: str,
157
- account_id: str,
158
- *,
159
- hints: list[dict] | None = None,
160
- answered: list[dict] | None = None,
161
- probes: list[str] | None = None,
162
- budget: Budget | None = None,
163
- ) -> Assessment:
164
- """形成一份暂时判断。
165
-
166
- 参数都是**上层观察到的事实**,不是模型猜的:
167
-
168
- hints 画像异常线索(来自 ProfileStore.watch)
169
- answered 对方答对了哪些共同经历 [{"summary":..., "trace_id":...}]
170
- probes 上层想问的方向,用来决定要不要拉回旧记忆
171
-
172
- 这一层不做自然语言理解。它只把已有材料组织成一个结论。
173
- """
174
- assessment = Assessment()
175
- profile = self.profiles.get(scope_id, account_id)
176
-
177
- # ---- 1. 异常线索:这是"不像"的那一半
178
- for hint in hints or []:
179
- kind = hint.get("kind", "")
180
- weight = _WEIGHTS.get(kind, 0.0)
181
- if weight == 0:
182
- continue
183
- assessment.evidence.append(Evidence(
184
- kind=kind,
185
- summary=_describe_hint(hint),
186
- weight=weight,
187
- trace_id=hint.get("evidence_trace_id"),
188
- ))
189
-
190
- # ---- 2. 答对的共同经历:这是"像"的那一半
191
- #
192
- # 注意 5.3:这一条**只影响判断,不影响授权**。
193
- # 答对了说明"他知道一些事",而知道不等于他是那个人。
194
- for hit in answered or []:
195
- assessment.evidence.append(Evidence(
196
- kind="answered_recall",
197
- summary=f"答对了:{hit.get('summary', '')}",
198
- weight=_WEIGHTS["answered_recall"],
199
- trace_id=hit.get("trace_id"),
200
- ))
201
-
202
- # ---- 3. 拉回共同经历。只在需要判断时才拉——
203
- # 常规对话不查这个,那是方案 9 说的"异常触发深度分析"。
204
- if hints or answered or probes:
205
- assessment.shared_memories = self._shared_memories(
206
- scope_id, probes or [], budget,
207
- )
208
-
209
- # ---- 4. 画像对得上吗
210
- if profile:
211
- fit = self._profile_fit(profile, hints or [])
212
- if fit is not None:
213
- assessment.evidence.append(fit)
214
-
215
- # ---- 5. 综合
216
- self._conclude(assessment)
217
-
218
- # ---- 6. 还差什么
219
- assessment.open_questions = self._open_questions(assessment, profile)
220
-
221
- # ---- 7. 授权:**独立计算**,不看 verdict
222
- assessment.may_share_sensitive = self.may_share_sensitive(
223
- scope_id, account_id,
224
- )
225
- return assessment
226
-
227
- def may_share_sensitive(self, scope_id: str,
228
- account_id: str) -> bool:
229
- """能不能把这个人的私人记忆给他看。
230
-
231
- **不看判断结果。** 理由写在模块说明里:
232
- 答对共同回忆不构成认证,共同回忆本来就会泄露。
233
-
234
- 三个条件,缺一不可:
235
-
236
- 1. 有画像,而且没被标成可疑
237
- 2. 没有 open 的"明说不是本人"线索
238
- 3. 至少有一条 confirmed 的身份类标签
239
-
240
- 第 3 条容易被跳过,但它是唯一能证明"这个账号跟这个人建立过关系"的东西。
241
- 没有它,一个刚建的画像也能拿到授权。
242
- """
243
- profile = self.profiles.get(scope_id, account_id)
244
- if profile is None:
245
- return False
246
- if profile["status"] != "active":
247
- return False
248
-
249
- for alert in self.profiles.open_alerts(scope_id, account_id):
250
- if alert["kind"] == "explicit_claim":
251
- return False
252
-
253
- confirmed = self.profiles.tags(scope_id, account_id, status="active")
254
- return any(
255
- t["source"] == "confirmed" and t["kind"] in ("address", "relation")
256
- for t in confirmed
257
- )
258
-
259
- # ---------------------------------------------------------- 内部
260
- def _shared_memories(self, scope_id: str, probes: list[str],
261
- budget: Budget | None) -> list[dict]:
262
- """拉回"我们之间发生过的事"。
263
-
264
- 检索词用 probes;没有 probes 就退化成一个宽查询。
265
- 预算给得很紧——这是**异常时才走**的路径,
266
- 常规对话不该把整段历史翻一遍(方案 9 成本控制)。
267
- """
268
- budget = budget or Budget(max_rounds=1, max_candidates=12,
269
- max_items=4, max_chars=800)
270
- text = " ".join(p for p in probes if p) or "我们"
271
- result = self.recall.pull(scope_id, text, limit=budget.max_items,
272
- budget=budget)
273
- return [
274
- {
275
- "content": t["content"],
276
- "trace_id": t["trace_id"],
277
- "priority": t.get("priority"),
278
- "anchor": t.get("anchor", False),
279
- }
280
- for t in result.traces
281
- ]
282
-
283
- def _profile_fit(self, profile: dict,
284
- hints: list[dict]) -> Evidence | None:
285
- """说的话跟画像对得上吗。
286
-
287
- 只做一个很粗的判断:有异常线索就是"不符",没有就是"没有反证"。
288
- 刻意不做"风格相似度"——那需要模型,而且它的误报会把
289
- "今天心情不好"判成"换了人"。
290
- """
291
- if hints:
292
- return Evidence(
293
- kind="profile_miss",
294
- summary="说的话跟画像不符",
295
- weight=_WEIGHTS["profile_miss"],
296
- )
297
- return Evidence(
298
- kind="profile_fit",
299
- summary="没有发现跟画像冲突的地方",
300
- weight=_WEIGHTS["profile_fit"],
301
- )
302
-
303
- def _conclude(self, a: Assessment) -> None:
304
- """把证据折成一个倾向。
305
-
306
- 阈值是刻意保守的:只有证据**明确**偏向一边才给结论,
307
- 否则一律 `unknown`。宁可说"判断不了",也不要给一个
308
- 看起来确定、其实靠猜的答案。
309
- """
310
- score = a.score
311
- if score >= 2.0:
312
- a.verdict = SAME
313
- # 置信度不追求高分:最高 0.85。留一截,
314
- # 是因为这个判断没有经过任何真正的认证。
315
- a.confidence = min(0.85, 0.4 + score * 0.1)
316
- a.reasons.append("有足够多指向同一个人的证据")
317
- elif score <= -2.0:
318
- a.verdict = DIFFERENT
319
- a.confidence = min(0.85, 0.4 + abs(score) * 0.1)
320
- a.reasons.append("有明确指向'不是本人'的证据")
321
- else:
322
- a.verdict = UNKNOWN
323
- a.confidence = 0.0
324
- a.reasons.append("证据不足以判断,先不下结论")
325
-
326
- def _open_questions(self, a: Assessment,
327
- profile: dict | None) -> list[str]:
328
- """还差什么。给方向,不给话术。"""
329
- if a.verdict == SAME:
330
- return []
331
- if profile is None:
332
- return ["还没有任何关于这个人的记录"]
333
-
334
- missing: list[str] = []
335
- if not a.shared_memories:
336
- missing.append("没有拉回到共同经历")
337
- if not any(e.weight > 0 for e in a.evidence):
338
- missing.append("没有支持'是本人'的证据")
339
- if any(e.kind == "explicit_claim" for e in a.evidence):
340
- missing.append("对方明说过不是本人,需要账号主人确认")
341
- return missing
342
-
343
-
344
- def _describe_hint(hint: dict) -> str:
345
- kind = hint.get("kind", "")
346
- if kind == "address_shift":
347
- return "自称的称呼跟以往不同"
348
- if kind == "explicit_claim":
349
- return "对方说不是本人"
350
- return kind or "未知线索"
1
+ """认知判断:把线索、共同回忆、画像组织成一个**暂时**结论。
2
+
3
+ 方案 2.1 C 说的是三件事:
4
+
5
+ 解释矛盾、评估人物一致性、调取共同回忆并形成暂时判断。
6
+
7
+ 关键词是「暂时」。这一层不宣布"这个人是谁"——
8
+ 它只回答"我现在更倾向哪种解释,凭什么,还差什么"。
9
+
10
+ ---
11
+
12
+ ## 为什么判断和授权必须分开
13
+
14
+ 5.3 有一句容易被忽略但很重的话:
15
+
16
+ > 回答正确不能自动开放私人信息。
17
+
18
+ 意思是:即使对方答对了你们的共同经历,也不等于他是本人。
19
+ 共同回忆会泄露(他自己说过、别人听过、猜也能猜个大概)。
20
+ 所以「我判断他是同一个人」和「我可以把私人记忆给他看」
21
+ 是**两个独立的问题**,不能用一个 verdict 同时回答。
22
+
23
+ `may_share_sensitive()` 因此**不看 verdict**。它只看:
24
+ 画像是不是可疑、有没有人明说过"我不是本人"。
25
+ 判断错了最坏是语气不对;授权错了是不可逆的泄露。
26
+
27
+ ## 为什么会有 `unknown`
28
+
29
+ 因为绝大多数情况真的判断不了。
30
+
31
+ 刚聊两句、对方只说了一句不像的话、你手上只有一条旧记忆——
32
+ 这些都是 `unknown`,而且**这就是正确答案**。
33
+ 强行给一个 same/different,只是把不确定性藏起来,
34
+ 而藏起来的不确定性会在后面变成更贵的错误。
35
+
36
+ 方案 5.1 也说得很清楚:主 AI「尝试解释」,不是「必须解释」。
37
+
38
+ ## 这一层不生成台词
39
+
40
+ 它给出的是「有什么证据、还差什么、能不能给信息」。
41
+ 要不要问「你还记得那次丢钱的事吗」——那是主 AI 的事。
42
+ 把试探话术写进这里,五种角色就会问出同一句话。
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from dataclasses import dataclass, field
48
+
49
+ from .profile import ProfileStore
50
+ from .recall import Budget, Recall
51
+
52
+ # 三种结论。`unknown` 是合法答案,不是失败。
53
+ SAME = "same_person"
54
+ DIFFERENT = "different_person"
55
+ UNKNOWN = "unknown"
56
+
57
+ # 证据的方向:正数指向"是同一个人",负数指向"不是"。
58
+ # 数值只用于排序与解释,不追求精确——这里没有"准确率"可谈,
59
+ # 因为真正的判断在主 AI 那里。
60
+ _WEIGHTS = {
61
+ "shared_memory": 1.0, # 对方主动提到只属于你们的经历
62
+ "answered_recall": 1.5, # 对方答对了你问的共同经历
63
+ "profile_fit": 0.5, # 说的话跟画像对得上
64
+ "explicit_claim": -3.0, # 明说"我不是本人"
65
+ "address_shift": -1.5, # 自称的称呼变了
66
+ "profile_miss": -1.0, # 说的话跟画像明显不符
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class Evidence:
72
+ """一条证据。必须能追到出处,否则它只是印象。"""
73
+
74
+ kind: str
75
+ summary: str
76
+ weight: float
77
+ trace_id: str | None = None
78
+
79
+ def as_dict(self) -> dict:
80
+ return {
81
+ "kind": self.kind,
82
+ "summary": self.summary,
83
+ "weight": self.weight,
84
+ "trace_id": self.trace_id,
85
+ }
86
+
87
+
88
+ @dataclass
89
+ class Assessment:
90
+ """一份**暂时**判断。
91
+
92
+ 没有 `is_this_person` 这种布尔字段,只有一个带置信度的倾向。
93
+ 布尔会诱使下游把它当事实用。
94
+ """
95
+
96
+ verdict: str = UNKNOWN
97
+ confidence: float = 0.0
98
+ evidence: list[Evidence] = field(default_factory=list)
99
+ shared_memories: list[dict] = field(default_factory=list)
100
+ # 还差什么。给主 AI 决定要不要继续问,以及问哪一类。
101
+ open_questions: list[str] = field(default_factory=list)
102
+ # 能不能给敏感信息。**与 verdict 无关**(5.3)。
103
+ may_share_sensitive: bool = False
104
+ reasons: list[str] = field(default_factory=list)
105
+
106
+ @property
107
+ def score(self) -> float:
108
+ return sum(e.weight for e in self.evidence)
109
+
110
+ def as_dict(self) -> dict:
111
+ return {
112
+ "verdict": self.verdict,
113
+ "confidence": round(self.confidence, 3),
114
+ "score": round(self.score, 3),
115
+ "evidence": [e.as_dict() for e in self.evidence],
116
+ "shared_memories": self.shared_memories,
117
+ "open_questions": self.open_questions,
118
+ "may_share_sensitive": self.may_share_sensitive,
119
+ "reasons": self.reasons,
120
+ }
121
+
122
+ def render(self) -> str:
123
+ """给人看的一段话。"""
124
+ names = {SAME: "像是同一个人", DIFFERENT: "不像同一个人",
125
+ UNKNOWN: "判断不了"}
126
+ lines = [f"倾向:{names[self.verdict]}(置信 {self.confidence:.2f})"]
127
+ if self.evidence:
128
+ lines.append("依据:")
129
+ for e in self.evidence:
130
+ mark = "+" if e.weight > 0 else "-"
131
+ lines.append(f" {mark} {e.summary}")
132
+ if self.shared_memories:
133
+ lines.append("你们的共同经历:")
134
+ for m in self.shared_memories:
135
+ lines.append(f" - {m['content']}")
136
+ if self.open_questions:
137
+ lines.append("还差:" + "、".join(self.open_questions))
138
+ lines.append(
139
+ "敏感信息:" + ("可以给" if self.may_share_sensitive else "不能给")
140
+ )
141
+ return "\n".join(lines)
142
+
143
+
144
+ class Judge:
145
+ """认知判断。只出结论,不改任何状态。"""
146
+
147
+ def __init__(self, engine, *, recall: Recall | None = None,
148
+ profiles: ProfileStore | None = None):
149
+ self.engine = engine
150
+ self.recall = recall or Recall(engine)
151
+ self.profiles = profiles or ProfileStore(engine)
152
+
153
+ # ---------------------------------------------------------- 判断
154
+ def assess(
155
+ self,
156
+ scope_id: str,
157
+ account_id: str,
158
+ *,
159
+ hints: list[dict] | None = None,
160
+ answered: list[dict] | None = None,
161
+ probes: list[str] | None = None,
162
+ budget: Budget | None = None,
163
+ ) -> Assessment:
164
+ """形成一份暂时判断。
165
+
166
+ 参数都是**上层观察到的事实**,不是模型猜的:
167
+
168
+ hints 画像异常线索(来自 ProfileStore.watch)
169
+ answered 对方答对了哪些共同经历 [{"summary":..., "trace_id":...}]
170
+ probes 上层想问的方向,用来决定要不要拉回旧记忆
171
+
172
+ 这一层不做自然语言理解。它只把已有材料组织成一个结论。
173
+ """
174
+ assessment = Assessment()
175
+ profile = self.profiles.get(scope_id, account_id)
176
+
177
+ # ---- 1. 异常线索:这是"不像"的那一半
178
+ for hint in hints or []:
179
+ kind = hint.get("kind", "")
180
+ weight = _WEIGHTS.get(kind, 0.0)
181
+ if weight == 0:
182
+ continue
183
+ assessment.evidence.append(Evidence(
184
+ kind=kind,
185
+ summary=_describe_hint(hint),
186
+ weight=weight,
187
+ trace_id=hint.get("evidence_trace_id"),
188
+ ))
189
+
190
+ # ---- 2. 答对的共同经历:这是"像"的那一半
191
+ #
192
+ # 注意 5.3:这一条**只影响判断,不影响授权**。
193
+ # 答对了说明"他知道一些事",而知道不等于他是那个人。
194
+ for hit in answered or []:
195
+ assessment.evidence.append(Evidence(
196
+ kind="answered_recall",
197
+ summary=f"答对了:{hit.get('summary', '')}",
198
+ weight=_WEIGHTS["answered_recall"],
199
+ trace_id=hit.get("trace_id"),
200
+ ))
201
+
202
+ # ---- 3. 拉回共同经历。只在需要判断时才拉——
203
+ # 常规对话不查这个,那是方案 9 说的"异常触发深度分析"。
204
+ if hints or answered or probes:
205
+ assessment.shared_memories = self._shared_memories(
206
+ scope_id, probes or [], budget,
207
+ )
208
+
209
+ # ---- 4. 画像对得上吗
210
+ if profile:
211
+ fit = self._profile_fit(profile, hints or [])
212
+ if fit is not None:
213
+ assessment.evidence.append(fit)
214
+
215
+ # ---- 5. 综合
216
+ self._conclude(assessment)
217
+
218
+ # ---- 6. 还差什么
219
+ assessment.open_questions = self._open_questions(assessment, profile)
220
+
221
+ # ---- 7. 授权:**独立计算**,不看 verdict
222
+ assessment.may_share_sensitive = self.may_share_sensitive(
223
+ scope_id, account_id,
224
+ )
225
+ return assessment
226
+
227
+ def may_share_sensitive(self, scope_id: str,
228
+ account_id: str) -> bool:
229
+ """能不能把这个人的私人记忆给他看。
230
+
231
+ **不看判断结果。** 理由写在模块说明里:
232
+ 答对共同回忆不构成认证,共同回忆本来就会泄露。
233
+
234
+ 三个条件,缺一不可:
235
+
236
+ 1. 有画像,而且没被标成可疑
237
+ 2. 没有 open 的"明说不是本人"线索
238
+ 3. 至少有一条 confirmed 的身份类标签
239
+
240
+ 第 3 条容易被跳过,但它是唯一能证明"这个账号跟这个人建立过关系"的东西。
241
+ 没有它,一个刚建的画像也能拿到授权。
242
+ """
243
+ profile = self.profiles.get(scope_id, account_id)
244
+ if profile is None:
245
+ return False
246
+ if profile["status"] != "active":
247
+ return False
248
+
249
+ for alert in self.profiles.open_alerts(scope_id, account_id):
250
+ if alert["kind"] == "explicit_claim":
251
+ return False
252
+
253
+ confirmed = self.profiles.tags(scope_id, account_id, status="active")
254
+ return any(
255
+ t["source"] == "confirmed" and t["kind"] in ("address", "relation")
256
+ for t in confirmed
257
+ )
258
+
259
+ # ---------------------------------------------------------- 内部
260
+ def _shared_memories(self, scope_id: str, probes: list[str],
261
+ budget: Budget | None) -> list[dict]:
262
+ """拉回"我们之间发生过的事"。
263
+
264
+ 检索词用 probes;没有 probes 就退化成一个宽查询。
265
+ 预算给得很紧——这是**异常时才走**的路径,
266
+ 常规对话不该把整段历史翻一遍(方案 9 成本控制)。
267
+ """
268
+ budget = budget or Budget(max_rounds=1, max_candidates=12,
269
+ max_items=4, max_chars=800)
270
+ text = " ".join(p for p in probes if p) or "我们"
271
+ result = self.recall.pull(scope_id, text, limit=budget.max_items,
272
+ budget=budget)
273
+ return [
274
+ {
275
+ "content": t["content"],
276
+ "trace_id": t["trace_id"],
277
+ "priority": t.get("priority"),
278
+ "anchor": t.get("anchor", False),
279
+ }
280
+ for t in result.traces
281
+ ]
282
+
283
+ def _profile_fit(self, profile: dict,
284
+ hints: list[dict]) -> Evidence | None:
285
+ """说的话跟画像对得上吗。
286
+
287
+ 只做一个很粗的判断:有异常线索就是"不符",没有就是"没有反证"。
288
+ 刻意不做"风格相似度"——那需要模型,而且它的误报会把
289
+ "今天心情不好"判成"换了人"。
290
+ """
291
+ if hints:
292
+ return Evidence(
293
+ kind="profile_miss",
294
+ summary="说的话跟画像不符",
295
+ weight=_WEIGHTS["profile_miss"],
296
+ )
297
+ return Evidence(
298
+ kind="profile_fit",
299
+ summary="没有发现跟画像冲突的地方",
300
+ weight=_WEIGHTS["profile_fit"],
301
+ )
302
+
303
+ def _conclude(self, a: Assessment) -> None:
304
+ """把证据折成一个倾向。
305
+
306
+ 阈值是刻意保守的:只有证据**明确**偏向一边才给结论,
307
+ 否则一律 `unknown`。宁可说"判断不了",也不要给一个
308
+ 看起来确定、其实靠猜的答案。
309
+ """
310
+ score = a.score
311
+ if score >= 2.0:
312
+ a.verdict = SAME
313
+ # 置信度不追求高分:最高 0.85。留一截,
314
+ # 是因为这个判断没有经过任何真正的认证。
315
+ a.confidence = min(0.85, 0.4 + score * 0.1)
316
+ a.reasons.append("有足够多指向同一个人的证据")
317
+ elif score <= -2.0:
318
+ a.verdict = DIFFERENT
319
+ a.confidence = min(0.85, 0.4 + abs(score) * 0.1)
320
+ a.reasons.append("有明确指向'不是本人'的证据")
321
+ else:
322
+ a.verdict = UNKNOWN
323
+ a.confidence = 0.0
324
+ a.reasons.append("证据不足以判断,先不下结论")
325
+
326
+ def _open_questions(self, a: Assessment,
327
+ profile: dict | None) -> list[str]:
328
+ """还差什么。给方向,不给话术。"""
329
+ if a.verdict == SAME:
330
+ return []
331
+ if profile is None:
332
+ return ["还没有任何关于这个人的记录"]
333
+
334
+ missing: list[str] = []
335
+ if not a.shared_memories:
336
+ missing.append("没有拉回到共同经历")
337
+ if not any(e.weight > 0 for e in a.evidence):
338
+ missing.append("没有支持'是本人'的证据")
339
+ if any(e.kind == "explicit_claim" for e in a.evidence):
340
+ missing.append("对方明说过不是本人,需要账号主人确认")
341
+ return missing
342
+
343
+
344
+ def _describe_hint(hint: dict) -> str:
345
+ kind = hint.get("kind", "")
346
+ if kind == "address_shift":
347
+ return "自称的称呼跟以往不同"
348
+ if kind == "explicit_claim":
349
+ return "对方说不是本人"
350
+ return kind or "未知线索"