dsh-plugin-xujie-memory 0.5.0 → 0.7.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.
Files changed (84) hide show
  1. package/ACCEPTANCE.md +37 -0
  2. package/BUILD-PROVENANCE.json +53 -0
  3. package/CHANGELOG.md +40 -1
  4. package/LICENSE +21 -21
  5. package/README.md +67 -17
  6. package/README_EN.md +54 -8
  7. package/cordis.patch.yml +49 -49
  8. package/lib/client.js +17219 -0
  9. package/lib/controller.js +307 -0
  10. package/lib/index.js +200 -14
  11. package/lib/typert.host.d.ts +3 -0
  12. package/lib/typert.host.js +15104 -0
  13. package/lib/typert.remote-client.d.ts +40 -0
  14. package/lib/typert.remote-client.d.ts.map +1 -0
  15. package/lib/typert.remote-client.js +15093 -0
  16. package/lib/types.d.ts +13 -0
  17. package/lib/types.js +1 -0
  18. package/package.json +49 -5
  19. package/python/specs/characters/aloof.json +14 -14
  20. package/python/specs/characters/gentle.json +14 -14
  21. package/python/specs/characters/tsundere.json +14 -14
  22. package/python/specs/debt.v1.json +31 -31
  23. package/python/specs/nickname.v1.json +16 -16
  24. package/python/specs/preference.v1.json +16 -16
  25. package/python/xujie_memory/__init__.py +123 -123
  26. package/python/xujie_memory/admission.py +211 -211
  27. package/python/xujie_memory/audit.py +377 -377
  28. package/python/xujie_memory/benchmark.py +281 -281
  29. package/python/xujie_memory/character.py +199 -199
  30. package/python/xujie_memory/console.py +41 -41
  31. package/python/xujie_memory/consolidate.py +356 -356
  32. package/python/xujie_memory/due.py +121 -121
  33. package/python/xujie_memory/emotion.py +573 -573
  34. package/python/xujie_memory/engine.py +1333 -1312
  35. package/python/xujie_memory/errors.py +98 -98
  36. package/python/xujie_memory/importance.py +104 -104
  37. package/python/xujie_memory/judge.py +350 -350
  38. package/python/xujie_memory/management.py +203 -0
  39. package/python/xujie_memory/memory.py +71 -10
  40. package/python/xujie_memory/orchestrator.py +690 -666
  41. package/python/xujie_memory/phrasing.py +292 -292
  42. package/python/xujie_memory/privacy.py +105 -0
  43. package/python/xujie_memory/schema.sql +492 -480
  44. package/python/xujie_memory/server.py +76 -0
  45. package/python/xujie_memory/specs.py +170 -170
  46. package/python/xujie_memory/summary.py +238 -238
  47. package/scripts/build.mjs +19 -0
  48. package/scripts/prepack.mjs +9 -0
  49. package/scripts/test-all.mjs +9 -0
  50. package/scripts/test-node.mjs +10 -0
  51. package/src/client/MemoryPanel.tsx +536 -0
  52. package/src/client/NeuralField.tsx +88 -0
  53. package/src/client/css.d.ts +4 -0
  54. package/src/client/icons.tsx +28 -0
  55. package/src/client/index.tsx +59 -0
  56. package/src/client/memory-adapter.ts +67 -0
  57. package/src/client/model.ts +79 -0
  58. package/src/client/neural/engine.ts +999 -0
  59. package/src/client/neural/morphology.ts +219 -0
  60. package/src/client/panel.css +337 -0
  61. package/src/client/types.ts +6 -0
  62. package/src/controller.ts +88 -0
  63. package/src/generated/typert.host.js +449 -0
  64. package/src/generated/typert.remote-client.js +438 -0
  65. package/src/index.ts +2 -0
  66. package/src/types.ts +13 -0
  67. package/test/cordis-fixture.mjs +5 -0
  68. package/test/management_regression.py +187 -0
  69. package/test/node-regression.entry.mjs +52 -0
  70. package/vendor/DSH-LICENSE.txt +21 -0
  71. package/vendor/THIRD-PARTY.md +6 -0
  72. package/vendor/dsh-protocol/index.ts +484 -0
  73. package/vendor/dsh-protocol/json-value.ts +53 -0
  74. package/vendor/dsh-protocol/owned-value.ts +39 -0
  75. package/vendor/dsh-protocol/principal.ts +17 -0
  76. package/vendor/dsh-protocol/remote-access.ts +38 -0
  77. package/vendor/dsh-protocol/remote-error.ts +49 -0
  78. package/vendor/dsh-protocol/remote-info.ts +161 -0
  79. package/vendor/dsh-protocol/types.ts +939 -0
  80. package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
  81. package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
  82. package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
  83. package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
  84. package/vendor/licenses/zod-MIT.txt +21 -0
@@ -1,666 +1,690 @@
1
- """认知协调器:一次交互的编排。
2
-
3
- 方案 2.2 的流程,落到一个 `handle()` 里:
4
-
5
- 用户输入
6
- → 观察(画像异常线索)
7
- → 记忆写入(判定层)
8
- → 【异常才】深度分析(判断层)
9
- → 按需检索
10
- → 情绪与关系
11
- → 组织材料
12
- → 交给主 AI 生成回应
13
-
14
- ---
15
-
16
- ## 它不生成台词
17
-
18
- 协调器给主 AI 的是一包**材料**:这个人是谁、现在什么心情、说过什么、
19
- 有没有需要留意的。**没有一句"你应该这么说"。**
20
-
21
- 理由和角色卡那条一样:话术一旦写进内核,所有角色就会说同一种话。
22
- 而方案 2.2 最后一步写的是「主 AI 生成最终回应」——那一步不在这个仓库里。
23
-
24
- ## 为什么深度分析要"异常才做"
25
-
26
- 方案第九章的成本控制,落成两条:
27
-
28
- 常规一轮:一次检索,几条记忆,不碰判断层
29
- 异常一轮:才调画像、共同回忆、矛盾证据
30
-
31
- 不做这个区分,每一句"今天天气不错"都会触发一次历史翻查——
32
- 那既慢又贵,而且**没有必要**:没有异常的时候,判断层给不出任何结论,
33
- 只会回一个 `unknown`。花着钱拿一个"我不知道",不如不花。
34
-
35
- ## 为什么情绪要由调用方给
36
-
37
- `feelings` 参数是结构化的(`summary / severity / anger / trust_delta`)。
38
- 协调器不猜"这句话该多生气"——那需要一个价值判断模型,
39
- 而且它的错误会直接改关系状态。
40
-
41
- 方案 2.2 里那一步叫「情绪与关系状态计算」,
42
- 而"算"这个字要求输入是确定的。**上半句是理解,下半句是算术。**
43
- 理解交给模型,算术留在这里。
44
- """
45
-
46
- from __future__ import annotations
47
-
48
- from dataclasses import dataclass, field
49
-
50
- from . import detect
51
- from . import importance as auto_importance
52
- from .encoder import RuleEncoder
53
- from .ingest import Ingest
54
- from .judge import Assessment, Judge
55
- from .profile import ProfileStore
56
- from .recall import Budget as RecallBudget
57
- from .recall import Recall
58
-
59
-
60
- # 澄清类型 → 一句人话。
61
- #
62
- # 内核只给数据不给措辞,但材料是**给模型读的**。
63
- # 直接甩一个 `missing_slot` 过去,模型要么忽略,要么照着念术语。
64
- _CLARIFICATION_LABELS = {
65
- "ambiguous_candidates": "同一句话能理解成好几种,还没选定是哪一种",
66
- "total_reading": "「一共多少」有两种读法,还没选定",
67
- "missing_slot": "缺一个关键信息(比如数量、对象)",
68
- "unconfirmed": "需要确认这句话是不是这个意思",
69
- }
70
-
71
-
72
- @dataclass
73
- class TurnBudget:
74
- """一轮对话能花多少。
75
-
76
- 默认值按"常规一轮"给;异常时上层可以放开 `deep_analysis`。
77
- """
78
-
79
- deep_analysis: bool = True # 允不允许在异常时做深度分析
80
- max_recall: int = 4 # 常规检索最多几条
81
- max_deep_recall: int = 3 # 深度分析最多拉几条共同回忆
82
- max_context_chars: int = 1500 # 喂给主 AI 的材料上限
83
- # ---- 澄清熔断 ----
84
- #
85
- # 默认值是按"一次普通交流能忍几次追问"定的,不是按"能问几次"定的。
86
- # 一轮最多 1 件、一个会话最多主动问 6 次——超过这个数,
87
- # 用户感受就从"这助手挺细心"变成"怎么老问"。
88
- max_questions: int = 1 # 一轮最多提几件还没确定的事
89
- max_asked_total: int = 6 # 一个会话累计问够这么多次就不再新开
90
-
91
-
92
- @dataclass
93
- class Turn:
94
- """一轮的结果。**材料,不是回应。**"""
95
-
96
- trace_id: str = ""
97
- # 记忆层:这句话被判定成什么、进了账没有
98
- outcomes: list = field(default_factory=list)
99
- clarifications: list = field(default_factory=list)
100
- # 画像层:发现了什么异常
101
- alerts: list = field(default_factory=list)
102
- # 判断层:只有异常时才不为 None
103
- assessment: Assessment | None = None
104
- # 检索:拉回了什么
105
- recalled: list = field(default_factory=list)
106
- # 给主 AI 的材料
107
- context: str = ""
108
- budget_used: dict = field(default_factory=dict)
109
- warnings: list[str] = field(default_factory=list)
110
-
111
- @property
112
- def escalated(self) -> bool:
113
- """这一轮有没有升级到深度分析。看日志时最想知道的就是这个。"""
114
- return self.assessment is not None
115
-
116
- def as_dict(self) -> dict:
117
- return {
118
- "trace_id": self.trace_id,
119
- "outcomes": [
120
- {"relation": o.interpretation.relation,
121
- "outcome": o.decision.outcome,
122
- "reasons": list(o.decision.reasons)}
123
- for o in self.outcomes
124
- ],
125
- "clarifications": [c.get("kind") for c in self.clarifications],
126
- "alerts": [a.get("kind") for a in self.alerts],
127
- "assessment": self.assessment.as_dict() if self.assessment else None,
128
- "recalled": [t["content"] for t in self.recalled],
129
- "anchored": [t["content"] for t in self.anchored],
130
- "budget_used": self.budget_used,
131
- "warnings": list(self.warnings),
132
- }
133
-
134
-
135
- class Orchestrator:
136
- """把五层串起来跑一轮。"""
137
-
138
- def __init__(
139
- self,
140
- engine,
141
- *,
142
- encoder=None,
143
- profiles: ProfileStore | None = None,
144
- judge: Judge | None = None,
145
- recall: Recall | None = None,
146
- mind=None,
147
- auto_confirm: bool = False,
148
- ):
149
- """`auto_confirm` 是**压测与演示**专用:模拟"用户会点头"。
150
-
151
- 生产环境不要开——澄清的存在意义就是等人回答,
152
- 自动点头等于把 assisted 关系悄悄升成 auto。
153
- """
154
- self.engine = engine
155
- self.profiles = profiles or ProfileStore(engine)
156
- self.recall = recall or Recall(engine)
157
- self.judge = judge or Judge(engine, recall=self.recall,
158
- profiles=self.profiles)
159
- self.ingest = Ingest(engine, encoder or RuleEncoder())
160
- self.mind = mind
161
- self.auto_confirm = auto_confirm
162
- # 一轮一个 source_ref。**不能只靠时间戳**:同一秒内可能来两句
163
- # (自动补测、并发用户、回放脚本),撞上幂等键就直接抛异常。
164
- self._seq = 0
165
- self._derive_turns: dict[str, int] = {}
166
-
167
- def handle(
168
- self,
169
- scope_id: str,
170
- account_id: str,
171
- text: str,
172
- *,
173
- actor_id: str | None = None,
174
- source_kind: str = "user_statement",
175
- context: dict | None = None,
176
- feelings: dict | None = None,
177
- budget: TurnBudget | None = None,
178
- probes: list[str] | None = None,
179
- session: str | None = None,
180
- ) -> Turn:
181
- """跑一轮。
182
-
183
- `feelings` 是结构化事件(见模块说明);给了才会改情绪与关系。
184
- `probes` 是"想问对方的方向",只在深度分析时用来拉共同回忆。
185
- """
186
- budget = budget or TurnBudget()
187
- ctx = dict(context or {})
188
- actor = actor_id or account_id
189
- turn = Turn()
190
-
191
- # ---- 1. 观察:这句话跟画像对得上吗
192
- #
193
- # 放在记忆写入之前。理由:观察只看这句话,不需要账本;
194
- # 而它一旦发现问题,后面的检索和判断都要跟着变。
195
- profile = self.profiles.ensure(scope_id, account_id)
196
- turn.alerts = self._observe(scope_id, account_id, text)
197
-
198
- # ---- 2. 记忆写入
199
- self._seq += 1
200
- result = self.ingest.ingest(
201
- scope_id, f"turn_{self.engine.now()}_{self._seq}", actor,
202
- source_kind, text, context=ctx,
203
- )
204
- turn.trace_id = result.trace_id
205
- turn.outcomes = list(result.outcomes)
206
- turn.clarifications = list(result.clarifications)
207
-
208
- # 自动重要性:只对用户陈述打分,注入/系统消息不参与。
209
- # 它只改「淡得慢一点」,不钉锚点;误判的代价是多留一阵。
210
- if source_kind == "user_statement":
211
- try:
212
- auto_importance.apply(self.engine, scope_id, result.trace_id, text)
213
- except Exception: # noqa: BLE001 —— 打分失败不该影响这一轮记忆
214
- pass
215
-
216
- if self.auto_confirm and result.clarifications:
217
- # 演示/压测专用:替用户点一次头。不点的话,那几句会一直挂着,
218
- # 画像就只是半截——看起来像"系统没记住",其实是"没收到确认"。
219
- for cl in result.clarifications:
220
- try:
221
- self.ingest.confirm(cl["request_id"], actor)
222
- except Exception: # noqa: BLE001
223
- pass
224
-
225
- # 把刚提交的事实同步进画像(幂等)。
226
- #
227
- # 画像从 `states` 派生,但**没人触发就不会派生**——
228
- # 少了这一步,账上有"不吃辣",卡片上还是"还不了解这个人",
229
- # 主 AI 拿着空卡去回答"今天吃什么",自然只能反问。
230
- # 放在这里而不是 `render()` 里:render 是读操作,不该有副作用。
231
- # v0.52:换人嫌疑要能自己解除。
232
- #
233
- # 宿主(DSH)里没有人会调用 `profiles.evaluate()`,于是一句「以后叫我老王」
234
- # 就让画像永久处于 suspect:之后所有新标签进暂存区、卡片上再也看不到,
235
- # 还每轮挂着「别把他当同一个人」。两条解除路径:
236
- # 1. 这一轮用户明确改了称呼并已入账 → 改称呼本身就是解释,判同一个人;
237
- # 2. 嫌疑超过 SUSPECT_TTL 秒没人处理 → 自动解除(线索留在库里可查)。
238
- self._settle_suspicion(scope_id, account_id, turn, result)
239
-
240
- # v0.52:只派生这一轮动过的状态;每个 scope 在进程里第一次、
241
- # 以及每 50 轮做一次全量对账兜底(澄清确认等间接改动不会漏太久)。
242
- self._derive_turns[scope_id] = self._derive_turns.get(scope_id, 0) + 1
243
- if self._derive_turns[scope_id] % 50 == 1:
244
- self.profiles.derive_from_memory(scope_id, account_id)
245
- else:
246
- touched = sorted({o.event.state_key for o in result.outcomes
247
- if getattr(o, "event", None) is not None})
248
- self.profiles.derive_from_memory(scope_id, account_id,
249
- state_keys=touched)
250
-
251
- # ---- 3. 异常才做深度分析(方案 9 的成本控制)
252
- if turn.alerts and budget.deep_analysis:
253
- hint_dicts = [
254
- {"kind": a["kind"], "evidence_trace_id": a.get("evidence_trace_id")}
255
- for a in turn.alerts
256
- ]
257
- turn.assessment = self.judge.assess(
258
- scope_id, account_id,
259
- hints=hint_dicts,
260
- probes=probes,
261
- budget=RecallBudget(max_rounds=1, max_candidates=12,
262
- max_items=budget.max_deep_recall,
263
- max_chars=800),
264
- )
265
- elif turn.alerts:
266
- # 预算关掉了深度分析。**必须说出来**——
267
- # 静默降级会让调用方以为"没有异常",那是两回事。
268
- turn.warnings.append("有异常线索,但本轮预算不允许深度分析")
269
-
270
- # ---- 4. 按需检索。排除当前这句——它不是"旧事"。
271
- turn.recalled = self._recall(scope_id, text, budget,
272
- exclude=turn.trace_id)
273
-
274
- # ---- 5. 情绪与关系(只在调用方给了结构化事件时才动)
275
- if feelings and self.mind is not None:
276
- self._apply_feelings(profile, feelings, turn)
277
-
278
- # ---- 6. 组织材料(先把锚点取出来——它不依赖检索)
279
- turn.anchored = self._anchored(scope_id)
280
- turn.context = self._compose(
281
- scope_id, account_id, profile, turn, budget, session=session,
282
- )
283
- turn.budget_used = {
284
- "deep_analysis": turn.escalated,
285
- "recalled": len(turn.recalled),
286
- "context_chars": len(turn.context),
287
- }
288
- return turn
289
-
290
- def compose_context(
291
- self,
292
- scope_id: str,
293
- account_id: str,
294
- text: str = "",
295
- *,
296
- budget: TurnBudget | None = None,
297
- session: str | None = None,
298
- ) -> str:
299
- """只读:给材料,**一个字都不写**。
300
-
301
- 宿主有时需要材料但没有新消息——开场白、切换会话、用户手动刷新。
302
- 那种时候不能调 `handle()`:它会记一笔账,而用户什么都没说。
303
-
304
- 用它,不要直接调 `_compose`:那是私方法,签名会随内部变化。
305
- """
306
- budget = budget or TurnBudget()
307
- profile = self.profiles.ensure(scope_id, account_id)
308
- turn = Turn()
309
- turn.anchored = self._anchored(scope_id)
310
- if text:
311
- turn.recalled = self._recall(scope_id, text, budget)
312
- # mark=False:读一次材料不等于问过用户。只读接口不该有副作用。
313
- return self._compose(scope_id, account_id, profile, turn, budget,
314
- mark=False, session=session)
315
-
316
- # ---------------------------------------------------------- 各步
317
- def _observe(self, scope_id: str, account_id: str,
318
- text: str) -> list[dict]:
319
- """轻量观察。只出线索,不下结论(4.5)。"""
320
- alert = self.profiles.watch(scope_id, account_id, text)
321
- if alert is None:
322
- return []
323
- # 顺带看一眼还有没有别的 open 线索——
324
- # 判断层需要全部线索,不是只有这一条。
325
- return self.profiles.open_alerts(scope_id, account_id)
326
-
327
- def _recall(self, scope_id: str, text: str, budget: TurnBudget,
328
- exclude: str = "") -> list[dict]:
329
- """常规检索。**用这句话本身当线索**,不额外猜。
330
-
331
- `exclude` 是当前这句的 trace:它不算"旧事"。
332
- 不过滤的话,材料里会出现「相关的旧事:我是他同事,帮我看看他的账」——
333
- 主 AI 会把它当成一件往事,而它其实是刚说的话。
334
- """
335
- if budget.max_recall <= 0:
336
- return []
337
- result = self.recall.pull(
338
- scope_id, text, limit=budget.max_recall + 1,
339
- budget=RecallBudget(max_rounds=1,
340
- max_candidates=budget.max_recall * 4,
341
- max_items=budget.max_recall + 1,
342
- max_chars=budget.max_context_chars // 2),
343
- exclude=exclude,
344
- )
345
- related = [
346
- {
347
- "content": t["content"],
348
- "trace_id": t["trace_id"],
349
- "priority": t.get("priority"),
350
- "importance": t.get("importance"),
351
- "anchor": t.get("anchor", False),
352
- "kind": "related",
353
- }
354
- for t in result.traces
355
- if t["trace_id"] != exclude
356
- ][: budget.max_recall]
357
- if related:
358
- return related
359
-
360
- # 检索不到就退化成"最近说过的事",并且**标明它不是相关的**。
361
- #
362
- # 为什么不干脆留空:材料里少一段,主 AI 会以为"这个人没说过别的话",
363
- # 而实际是"这句 query 没匹配上"。那是两种完全不同的信息。
364
- # 也不冒充成"相关的旧事"——最近说的和相关的经常不是一回事。
365
- return self._recent(scope_id, exclude, budget.max_recall)
366
-
367
- def _recent(self, scope_id: str, exclude: str,
368
- limit: int) -> list[dict]:
369
- """最近说过的话。**只用来兜底,标签必须是 recent。**
370
-
371
- `answer-%` 是澄清回答("确认"/"是这条"),不是用户说过的事。
372
- 把它当材料喂进去,主 AI 会以为"他最近说了句确认"——
373
- 那不是事实,是协议的一部分。
374
- """
375
- rows = self.engine._conn().execute(
376
- "SELECT trace_id, content FROM traces"
377
- " WHERE scope_id=? AND trace_id!=? AND erased_at IS NULL"
378
- " AND source_ref NOT LIKE 'answer-%'"
379
- " ORDER BY recorded_at DESC LIMIT ?",
380
- (scope_id, exclude, limit),
381
- ).fetchall()
382
- return [
383
- {"content": r["content"], "trace_id": r["trace_id"],
384
- "priority": None, "anchor": False, "kind": "recent"}
385
- for r in rows
386
- ]
387
-
388
- def _apply_feelings(self, profile: dict, feelings: dict,
389
- turn: Turn) -> None:
390
- """按结构化事件改情绪与关系。
391
-
392
- 缺字段就报错,不补默认值:
393
- 补一个 `severity=1` 等于替调用方决定了"这事有多严重",
394
- 而那个决定不该由这里做。
395
- """
396
- need = ("summary", "severity", "anger", "trust_delta", "reason")
397
- missing = [k for k in need if k not in feelings]
398
- if missing:
399
- turn.warnings.append(f"感受事件缺少字段: {', '.join(missing)}")
400
- return
401
- self.mind.offended(
402
- profile,
403
- summary=feelings["summary"],
404
- severity=float(feelings["severity"]),
405
- anger=float(feelings["anger"]),
406
- trust_delta=float(feelings["trust_delta"]),
407
- reason=feelings["reason"],
408
- evidence_trace_id=turn.trace_id or None,
409
- )
410
-
411
- def _anchored(self, scope_id: str, limit: int = 5) -> list[dict]:
412
- """被用户要求"记住"的话。
413
-
414
- **这一段必须无条件进材料。** 锚点在库里的意思是"不随时间淡去",
415
- 但如果它没进上下文,主 AI 就看不到它——那和忘了没区别。
416
-
417
- 上一版就是这样:锚点确实设了,`priority` 恒为 1.0,
418
- 可它出现在材料里只是因为"还很新"(排在最近几句话里)。
419
- 聊到第 20 轮,它掉出最近列表,从此消失。
420
-
421
- 锚住却看不见,等于没锚。这一段的成本是几条短句,
422
- 换来的是"用户明确要求的事不会丢"。
423
- """
424
- from .decay import Lifecycle
425
-
426
- out: list[dict] = []
427
- for item in Lifecycle(self.engine).anchored(scope_id):
428
- trace = self.engine.get_trace(item["ref_id"])
429
- if not trace:
430
- continue
431
- text = trace["content"]
432
- # 「记住,」这个前缀是指令,念给主 AI 听没有意义。
433
- body = detect.remember_body(text) or text
434
- out.append({
435
- "content": body,
436
- "raw": text,
437
- "trace_id": item["ref_id"],
438
- "priority": item.get("priority"),
439
- })
440
- if len(out) >= limit:
441
- break
442
- return out
443
-
444
- SUSPECT_TTL = 1800
445
-
446
- def _settle_suspicion(self, scope_id: str, account_id: str, turn: Turn,
447
- result) -> None:
448
- from datetime import datetime, timezone
449
- profile = self.profiles.get(scope_id, account_id)
450
- if not profile or profile.get("status") != "suspect":
451
- return
452
- alerts = self.profiles.open_alerts(scope_id, account_id)
453
- renamed = any(
454
- o.interpretation.relation == "nickname" and o.decision.outcome == "commit"
455
- for o in result.outcomes
456
- )
457
- only_address = bool(alerts) and all(a["kind"] == "address_shift" for a in alerts)
458
- expired = False
459
- try:
460
- since = datetime.fromisoformat(str(profile.get("updated_at")))
461
- now = datetime.fromisoformat(self.engine.now())
462
- if since.tzinfo is None:
463
- since = since.replace(tzinfo=timezone.utc)
464
- if now.tzinfo is None:
465
- now = now.replace(tzinfo=timezone.utc)
466
- expired = (now - since).total_seconds() > self.SUSPECT_TTL
467
- except (TypeError, ValueError):
468
- expired = False
469
- if (renamed and only_address) or expired or not alerts:
470
- reason = "用户明确改了称呼" if renamed else "嫌疑超时自动解除"
471
- for a in alerts:
472
- self.profiles.evaluate(a["alert_id"], "same_person", [reason],
473
- actor_id="system")
474
- self.profiles.restore(scope_id, account_id)
475
- turn.alerts = []
476
-
477
- def _debt_lines(self, scope_id: str, account_id: str, limit: int = 6) -> list[str]:
478
- import json as _json
479
- conn = self.engine._conn()
480
- rows = conn.execute(
481
- "SELECT state_key, value_json FROM states WHERE scope_id=?"
482
- " AND status='known' AND state_key LIKE ? LIMIT 50",
483
- (scope_id, f"{scope_id}|%|debt|%"),
484
- ).fetchall()
485
- lines: list[str] = []
486
- for r in rows:
487
- parts = r["state_key"].split("|")
488
- if len(parts) < 4:
489
- continue
490
- debtor, creditor = parts[1], parts[3]
491
- try:
492
- value = _json.loads(r["value_json"]) if r["value_json"] else None
493
- except ValueError:
494
- value = None
495
- if not isinstance(value, (int, float)) or value == 0:
496
- continue
497
- ev = conn.execute(
498
- "SELECT payload_json FROM events WHERE state_key=? AND void=0"
499
- " AND payload_json LIKE '%\"unit\":\"%'"
500
- " ORDER BY sequence_no DESC LIMIT 1",
501
- (r["state_key"],),
502
- ).fetchone()
503
- unit = ""
504
- if ev and ev["payload_json"]:
505
- try:
506
- unit = str(_json.loads(ev["payload_json"]).get("unit") or "")
507
- except (ValueError, AttributeError):
508
- unit = ""
509
- who = {"assistant": "你(助手)", "peer": "你(助手)"}.get(creditor, creditor)
510
- amount = f"{value:g}{unit}"
511
- if debtor == account_id:
512
- lines.append(f"- 他欠{who} {amount}")
513
- else:
514
- lines.append(f"- {debtor} 欠{who} {amount}")
515
- if len(lines) >= limit:
516
- break
517
- return lines
518
-
519
- def _clarification_quote(self, row: dict, limit: int = 40) -> str:
520
- """澄清对应的那句原话(截短)。找不到就返回空串。"""
521
- import json as _json
522
- try:
523
- ids = _json.loads(row.get("interpretation_ids_json") or "[]")
524
- except ValueError:
525
- return ""
526
- if not ids:
527
- return ""
528
- got = self.engine._conn().execute(
529
- "SELECT t.content FROM interpretations i JOIN traces t"
530
- " ON t.trace_id = i.trace_id WHERE i.interpretation_id=? LIMIT 1",
531
- (ids[0],),
532
- ).fetchone()
533
- if not got:
534
- return ""
535
- text = " ".join(str(got["content"]).split())
536
- return text if len(text) <= limit else text[: limit - 1] + "…"
537
-
538
- # ---------------------------------------------------------- 组装
539
- def _compose(self, scope_id: str, account_id: str, profile: dict,
540
- turn: Turn, budget: TurnBudget, *, mark: bool = True,
541
- session: str | None = None) -> str:
542
- """把材料拼成给主 AI 的一段话。
543
-
544
- 分四段,因为这个顺序就是主 AI 该有的思考顺序:
545
- 先知道对面是谁,再知道自己什么状态,再看相关的旧事,
546
- 最后才是"有没有要留意的"。
547
- """
548
- blocks: list[str] = []
549
-
550
- # 1. 这个人
551
- card = self.profiles.render(scope_id, account_id)
552
- blocks.append("## 关于这个人\n" + card)
553
-
554
- # 2. 用户要求记住的事。**排在画像之后、状态之前**——
555
- # 它比"我现在什么心情"更该先被看见。
556
- if turn.anchored:
557
- items = "\n".join(f"- {t['content']}" for t in turn.anchored)
558
- blocks.append(
559
- "## 重要的事(他专门说过要记住,不会随时间淡去)\n" + items)
560
-
561
- # 2.5 账和约定(v0.52):已经入账的欠款直接给出余额。
562
- # 以前账只在「相关的旧事」里以原话出现,余额要模型自己算,
563
- # 还经常因为检索没命中而整段消失。
564
- debts = self._debt_lines(scope_id, account_id)
565
- if debts:
566
- blocks.append("## 账目(已确认)\n" + "\n".join(debts))
567
-
568
- # 3. 我现在的状态
569
- if self.mind is not None:
570
- blocks.append("## 我现在的状态\n" + self.mind.render(profile))
571
-
572
- # 4. 相关的旧事 / 最近说过的话
573
- #
574
- # 两段分开写。混在一起,主 AI 会把"最近说的"当成"与本题相关的",
575
- # 于是拿一句无关的旧话去解释眼前的事。
576
- if turn.recalled:
577
- related = [t for t in turn.recalled if t.get("kind") == "related"]
578
- recent = [t for t in turn.recalled if t.get("kind") == "recent"]
579
- if related:
580
- def _tag(t: dict) -> str:
581
- if t.get("anchor"):
582
- return "(重点)"
583
- if float(t.get("importance") or 0) >= 3.0:
584
- return "(重要)"
585
- return ""
586
-
587
- items = "\n".join(
588
- f"- {t['content']}{_tag(t)}" for t in related
589
- )
590
- blocks.append("## 相关的旧事\n" + items)
591
- if recent:
592
- items = "\n".join(f"- {t['content']}" for t in recent)
593
- blocks.append("## 最近说过的话(不是检索命中的,仅供参考)\n"
594
- + items)
595
-
596
- # 5. 还没确定的事
597
- #
598
- # 和「要留意的」分开:那段说的是**这一轮**的问题,
599
- # 这段说的是**跨轮悬着**的问题。
600
- # 混在一起,模型会以为"这句话需要问",而实际是"三天前那句还没定"。
601
- #
602
- # 宿主形态下这一步是必需的:DSH 没有"点确认"的交互,
603
- # 澄清开出来就永远悬着——问过却不再提,等于没问。
604
- #
605
- # ## 但必须熔断
606
- #
607
- # 每轮都念一遍「有件事还没确定」,用户会被同一句话连问五遍。
608
- # 那不是澄清,是审问。三道闸:
609
- #
610
- # 1. 每条只念一次 —— 念过就标 asked,不再进材料
611
- # 2. 一轮最多提几条 —— budget.max_questions
612
- # 3. 累计问够就不再新开 —— budget.max_asked_total
613
- #
614
- # 熔断之后材料里**只剩一句总数**,不说"去问一句"。
615
- # 用户主动提起来时,那些记录还在库里,不会丢。
616
- unasked = self.ingest.unasked_clarifications(scope_id)
617
- asked_total = self.ingest.asked_count(scope_id, session)
618
- if unasked and asked_total < budget.max_asked_total:
619
- shown = unasked[: budget.max_questions]
620
- # v0.52:带上原话。只写「缺一个关键信息」,主模型不知道是哪句话,
621
- # 也就无从问起。
622
- lines = []
623
- for p in shown:
624
- label = _CLARIFICATION_LABELS.get(p["kind"], "有一处还没说清楚")
625
- quote = self._clarification_quote(p)
626
- lines.append(f"- 「{quote}」:{label}" if quote else "- " + label)
627
- # 「当前这句」而不是「下面这句」:这段文字进的是 systemPrompt,
628
- # 用户那句话在另一条消息里,不在这段下面。
629
- hint = ("\n(如果用户当前这句其实已经说清楚了,就当它确认了;"
630
- "否则**最多挑一件**顺带问一句,别追问)")
631
- tag = "" if len(unasked) <= len(shown) else f",另有 {len(unasked) - len(shown)} 件"
632
- blocks.append(
633
- "## 还没确定的事" + tag + "\n" + "\n".join(lines) + hint
634
- )
635
- # 只读调用(`/context`)不标记:读一次材料不等于问过用户。
636
- if mark:
637
- self.ingest.mark_asked([p["request_id"] for p in shown],
638
- session)
639
- elif unasked:
640
- # 熔断:问够了。只报个数,不再引导追问。
641
- blocks.append(
642
- f"## 还没确定的事\n- 有 {len(unasked)} 处还没确认"
643
- "(已经问过几次了,别主动追问;他主动提起来再说)"
644
- )
645
-
646
- # 6. 要留意的
647
- notes: list[str] = []
648
- if turn.assessment is not None:
649
- notes.append(turn.assessment.render())
650
- if turn.clarifications:
651
- notes.append(f"这句话还没确认清楚,需要问一句"
652
- f"({len(turn.clarifications)} 处)")
653
- if turn.warnings:
654
- notes.extend(turn.warnings)
655
- if notes:
656
- blocks.append("## 要留意的\n" + "\n".join(notes))
657
-
658
- text = "\n\n".join(blocks)
659
- if len(text) > budget.max_context_chars:
660
- # 截断要留痕,不能悄悄切掉——主 AI 不知道被截了,
661
- # 就会以为自己看到的是全部。
662
- text = text[: budget.max_context_chars]
663
- turn.warnings.append(
664
- f"材料超过 {budget.max_context_chars} 字,已截断",
665
- )
666
- return text
1
+ """认知协调器:一次交互的编排。
2
+
3
+ 方案 2.2 的流程,落到一个 `handle()` 里:
4
+
5
+ 用户输入
6
+ → 观察(画像异常线索)
7
+ → 记忆写入(判定层)
8
+ → 【异常才】深度分析(判断层)
9
+ → 按需检索
10
+ → 情绪与关系
11
+ → 组织材料
12
+ → 交给主 AI 生成回应
13
+
14
+ ---
15
+
16
+ ## 它不生成台词
17
+
18
+ 协调器给主 AI 的是一包**材料**:这个人是谁、现在什么心情、说过什么、
19
+ 有没有需要留意的。**没有一句"你应该这么说"。**
20
+
21
+ 理由和角色卡那条一样:话术一旦写进内核,所有角色就会说同一种话。
22
+ 而方案 2.2 最后一步写的是「主 AI 生成最终回应」——那一步不在这个仓库里。
23
+
24
+ ## 为什么深度分析要"异常才做"
25
+
26
+ 方案第九章的成本控制,落成两条:
27
+
28
+ 常规一轮:一次检索,几条记忆,不碰判断层
29
+ 异常一轮:才调画像、共同回忆、矛盾证据
30
+
31
+ 不做这个区分,每一句"今天天气不错"都会触发一次历史翻查——
32
+ 那既慢又贵,而且**没有必要**:没有异常的时候,判断层给不出任何结论,
33
+ 只会回一个 `unknown`。花着钱拿一个"我不知道",不如不花。
34
+
35
+ ## 为什么情绪要由调用方给
36
+
37
+ `feelings` 参数是结构化的(`summary / severity / anger / trust_delta`)。
38
+ 协调器不猜"这句话该多生气"——那需要一个价值判断模型,
39
+ 而且它的错误会直接改关系状态。
40
+
41
+ 方案 2.2 里那一步叫「情绪与关系状态计算」,
42
+ 而"算"这个字要求输入是确定的。**上半句是理解,下半句是算术。**
43
+ 理解交给模型,算术留在这里。
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from dataclasses import dataclass, field
49
+
50
+ from . import detect
51
+ from . import importance as auto_importance
52
+ from .encoder import RuleEncoder
53
+ from .ingest import Ingest
54
+ from .judge import Assessment, Judge
55
+ from .profile import ProfileStore
56
+ from .recall import Budget as RecallBudget
57
+ from .recall import Recall
58
+
59
+
60
+ def _echo_key(text: str) -> str:
61
+ """比对「是不是同一句」用的键:去掉空白与标点,只留字。
62
+
63
+ 为什么不能直接比字符串:宿主可能把同一句话分块送来、带换行,
64
+ 或者用户自己重打了一遍标点不同的话——那些都还是「刚说的这句」,
65
+ 不该被当成一件往事喂回去。
66
+ """
67
+ keep = []
68
+ for ch in (text or ""):
69
+ if ch.isspace() or ch in ",。!?、;:“”‘’()()[]【】,.!?;:\"'`~…—-":
70
+ continue
71
+ keep.append(ch.lower())
72
+ return "".join(keep)
73
+
74
+
75
+ # 澄清类型 → 一句人话。
76
+ #
77
+ # 内核只给数据不给措辞,但材料是**给模型读的**。
78
+ # 直接甩一个 `missing_slot` 过去,模型要么忽略,要么照着念术语。
79
+ _CLARIFICATION_LABELS = {
80
+ "ambiguous_candidates": "同一句话能理解成好几种,还没选定是哪一种",
81
+ "total_reading": "「一共多少」有两种读法,还没选定",
82
+ "missing_slot": "缺一个关键信息(比如数量、对象)",
83
+ "unconfirmed": "需要确认这句话是不是这个意思",
84
+ }
85
+
86
+
87
+ @dataclass
88
+ class TurnBudget:
89
+ """一轮对话能花多少。
90
+
91
+ 默认值按"常规一轮"给;异常时上层可以放开 `deep_analysis`。
92
+ """
93
+
94
+ deep_analysis: bool = True # 允不允许在异常时做深度分析
95
+ max_recall: int = 4 # 常规检索最多几条
96
+ max_deep_recall: int = 3 # 深度分析最多拉几条共同回忆
97
+ max_context_chars: int = 1500 # 喂给主 AI 的材料上限
98
+ # ---- 澄清熔断 ----
99
+ #
100
+ # 默认值是按"一次普通交流能忍几次追问"定的,不是按"能问几次"定的。
101
+ # 一轮最多 1 件、一个会话最多主动问 6 次——超过这个数,
102
+ # 用户感受就从"这助手挺细心"变成"怎么老问"。
103
+ max_questions: int = 1 # 一轮最多提几件还没确定的事
104
+ max_asked_total: int = 6 # 一个会话累计问够这么多次就不再新开
105
+
106
+
107
+ @dataclass
108
+ class Turn:
109
+ """一轮的结果。**材料,不是回应。**"""
110
+
111
+ trace_id: str = ""
112
+ # 记忆层:这句话被判定成什么、进了账没有
113
+ outcomes: list = field(default_factory=list)
114
+ clarifications: list = field(default_factory=list)
115
+ # 画像层:发现了什么异常
116
+ alerts: list = field(default_factory=list)
117
+ # 判断层:只有异常时才不为 None
118
+ assessment: Assessment | None = None
119
+ # 检索:拉回了什么
120
+ recalled: list = field(default_factory=list)
121
+ # 给主 AI 的材料
122
+ context: str = ""
123
+ budget_used: dict = field(default_factory=dict)
124
+ warnings: list[str] = field(default_factory=list)
125
+
126
+ @property
127
+ def escalated(self) -> bool:
128
+ """这一轮有没有升级到深度分析。看日志时最想知道的就是这个。"""
129
+ return self.assessment is not None
130
+
131
+ def as_dict(self) -> dict:
132
+ return {
133
+ "trace_id": self.trace_id,
134
+ "outcomes": [
135
+ {"relation": o.interpretation.relation,
136
+ "outcome": o.decision.outcome,
137
+ "reasons": list(o.decision.reasons)}
138
+ for o in self.outcomes
139
+ ],
140
+ "clarifications": [c.get("kind") for c in self.clarifications],
141
+ "alerts": [a.get("kind") for a in self.alerts],
142
+ "assessment": self.assessment.as_dict() if self.assessment else None,
143
+ "recalled": [t["content"] for t in self.recalled],
144
+ "anchored": [t["content"] for t in self.anchored],
145
+ "budget_used": self.budget_used,
146
+ "warnings": list(self.warnings),
147
+ }
148
+
149
+
150
+ class Orchestrator:
151
+ """把五层串起来跑一轮。"""
152
+
153
+ def __init__(
154
+ self,
155
+ engine,
156
+ *,
157
+ encoder=None,
158
+ profiles: ProfileStore | None = None,
159
+ judge: Judge | None = None,
160
+ recall: Recall | None = None,
161
+ mind=None,
162
+ auto_confirm: bool = False,
163
+ ):
164
+ """`auto_confirm` 是**压测与演示**专用:模拟"用户会点头"。
165
+
166
+ 生产环境不要开——澄清的存在意义就是等人回答,
167
+ 自动点头等于把 assisted 关系悄悄升成 auto。
168
+ """
169
+ self.engine = engine
170
+ self.profiles = profiles or ProfileStore(engine)
171
+ self.recall = recall or Recall(engine)
172
+ self.judge = judge or Judge(engine, recall=self.recall,
173
+ profiles=self.profiles)
174
+ self.ingest = Ingest(engine, encoder or RuleEncoder())
175
+ self.mind = mind
176
+ self.auto_confirm = auto_confirm
177
+ # 一轮一个 source_ref。**不能只靠时间戳**:同一秒内可能来两句
178
+ # (自动补测、并发用户、回放脚本),撞上幂等键就直接抛异常。
179
+ self._seq = 0
180
+ self._derive_turns: dict[str, int] = {}
181
+
182
+ def handle(
183
+ self,
184
+ scope_id: str,
185
+ account_id: str,
186
+ text: str,
187
+ *,
188
+ actor_id: str | None = None,
189
+ source_kind: str = "user_statement",
190
+ context: dict | None = None,
191
+ feelings: dict | None = None,
192
+ budget: TurnBudget | None = None,
193
+ probes: list[str] | None = None,
194
+ session: str | None = None,
195
+ ) -> Turn:
196
+ """跑一轮。
197
+
198
+ `feelings` 是结构化事件(见模块说明);给了才会改情绪与关系。
199
+ `probes` 是"想问对方的方向",只在深度分析时用来拉共同回忆。
200
+ """
201
+ budget = budget or TurnBudget()
202
+ ctx = dict(context or {})
203
+ actor = actor_id or account_id
204
+ turn = Turn()
205
+
206
+ # ---- 1. 观察:这句话跟画像对得上吗
207
+ #
208
+ # 放在记忆写入之前。理由:观察只看这句话,不需要账本;
209
+ # 而它一旦发现问题,后面的检索和判断都要跟着变。
210
+ profile = self.profiles.ensure(scope_id, account_id)
211
+ turn.alerts = self._observe(scope_id, account_id, text)
212
+
213
+ # ---- 2. 记忆写入
214
+ self._seq += 1
215
+ result = self.ingest.ingest(
216
+ scope_id, f"turn_{self.engine.now()}_{self._seq}", actor,
217
+ source_kind, text, context=ctx,
218
+ )
219
+ turn.trace_id = result.trace_id
220
+ turn.outcomes = list(result.outcomes)
221
+ turn.clarifications = list(result.clarifications)
222
+
223
+ # 自动重要性:只对用户陈述打分,注入/系统消息不参与。
224
+ # 它只改「淡得慢一点」,不钉锚点;误判的代价是多留一阵。
225
+ if source_kind == "user_statement":
226
+ try:
227
+ auto_importance.apply(self.engine, scope_id, result.trace_id, text)
228
+ except Exception: # noqa: BLE001 —— 打分失败不该影响这一轮记忆
229
+ pass
230
+
231
+ if self.auto_confirm and result.clarifications:
232
+ # 演示/压测专用:替用户点一次头。不点的话,那几句会一直挂着,
233
+ # 画像就只是半截——看起来像"系统没记住",其实是"没收到确认"。
234
+ for cl in result.clarifications:
235
+ try:
236
+ self.ingest.confirm(cl["request_id"], actor)
237
+ except Exception: # noqa: BLE001
238
+ pass
239
+
240
+ # 把刚提交的事实同步进画像(幂等)。
241
+ #
242
+ # 画像从 `states` 派生,但**没人触发就不会派生**——
243
+ # 少了这一步,账上有"不吃辣",卡片上还是"还不了解这个人",
244
+ # 主 AI 拿着空卡去回答"今天吃什么",自然只能反问。
245
+ # 放在这里而不是 `render()` 里:render 是读操作,不该有副作用。
246
+ # v0.52:换人嫌疑要能自己解除。
247
+ #
248
+ # 宿主(DSH)里没有人会调用 `profiles.evaluate()`,于是一句「以后叫我老王」
249
+ # 就让画像永久处于 suspect:之后所有新标签进暂存区、卡片上再也看不到,
250
+ # 还每轮挂着「别把他当同一个人」。两条解除路径:
251
+ # 1. 这一轮用户明确改了称呼并已入账 → 改称呼本身就是解释,判同一个人;
252
+ # 2. 嫌疑超过 SUSPECT_TTL 秒没人处理 → 自动解除(线索留在库里可查)。
253
+ self._settle_suspicion(scope_id, account_id, turn, result)
254
+
255
+ # v0.52:只派生这一轮动过的状态;每个 scope 在进程里第一次、
256
+ # 以及每 50 轮做一次全量对账兜底(澄清确认等间接改动不会漏太久)。
257
+ self._derive_turns[scope_id] = self._derive_turns.get(scope_id, 0) + 1
258
+ if self._derive_turns[scope_id] % 50 == 1:
259
+ self.profiles.derive_from_memory(scope_id, account_id)
260
+ else:
261
+ touched = sorted({o.event.state_key for o in result.outcomes
262
+ if getattr(o, "event", None) is not None})
263
+ self.profiles.derive_from_memory(scope_id, account_id,
264
+ state_keys=touched)
265
+
266
+ # ---- 3. 异常才做深度分析(方案 9 的成本控制)
267
+ if turn.alerts and budget.deep_analysis:
268
+ hint_dicts = [
269
+ {"kind": a["kind"], "evidence_trace_id": a.get("evidence_trace_id")}
270
+ for a in turn.alerts
271
+ ]
272
+ turn.assessment = self.judge.assess(
273
+ scope_id, account_id,
274
+ hints=hint_dicts,
275
+ probes=probes,
276
+ budget=RecallBudget(max_rounds=1, max_candidates=12,
277
+ max_items=budget.max_deep_recall,
278
+ max_chars=800),
279
+ )
280
+ elif turn.alerts:
281
+ # 预算关掉了深度分析。**必须说出来**——
282
+ # 静默降级会让调用方以为"没有异常",那是两回事。
283
+ turn.warnings.append("有异常线索,但本轮预算不允许深度分析")
284
+
285
+ # ---- 4. 按需检索。排除当前这句——它不是"旧事"。
286
+ turn.recalled = self._recall(scope_id, text, budget,
287
+ exclude=turn.trace_id)
288
+
289
+ # ---- 5. 情绪与关系(只在调用方给了结构化事件时才动)
290
+ if feelings and self.mind is not None:
291
+ self._apply_feelings(profile, feelings, turn)
292
+
293
+ # ---- 6. 组织材料(先把锚点取出来——它不依赖检索)
294
+ turn.anchored = self._anchored(scope_id)
295
+ turn.context = self._compose(
296
+ scope_id, account_id, profile, turn, budget, session=session,
297
+ )
298
+ turn.budget_used = {
299
+ "deep_analysis": turn.escalated,
300
+ "recalled": len(turn.recalled),
301
+ "context_chars": len(turn.context),
302
+ }
303
+ return turn
304
+
305
+ def compose_context(
306
+ self,
307
+ scope_id: str,
308
+ account_id: str,
309
+ text: str = "",
310
+ *,
311
+ budget: TurnBudget | None = None,
312
+ session: str | None = None,
313
+ ) -> str:
314
+ """只读:给材料,**一个字都不写**。
315
+
316
+ 宿主有时需要材料但没有新消息——开场白、切换会话、用户手动刷新。
317
+ 那种时候不能调 `handle()`:它会记一笔账,而用户什么都没说。
318
+
319
+ 用它,不要直接调 `_compose`:那是私方法,签名会随内部变化。
320
+ """
321
+ budget = budget or TurnBudget()
322
+ profile = self.profiles.ensure(scope_id, account_id)
323
+ turn = Turn()
324
+ turn.anchored = self._anchored(scope_id)
325
+ if text:
326
+ turn.recalled = self._recall(scope_id, text, budget)
327
+ # mark=False:读一次材料不等于问过用户。只读接口不该有副作用。
328
+ return self._compose(scope_id, account_id, profile, turn, budget,
329
+ mark=False, session=session)
330
+
331
+ # ---------------------------------------------------------- 各步
332
+ def _observe(self, scope_id: str, account_id: str,
333
+ text: str) -> list[dict]:
334
+ """轻量观察。只出线索,不下结论(4.5)。"""
335
+ alert = self.profiles.watch(scope_id, account_id, text)
336
+ if alert is None:
337
+ return []
338
+ # 顺带看一眼还有没有别的 open 线索——
339
+ # 判断层需要全部线索,不是只有这一条。
340
+ return self.profiles.open_alerts(scope_id, account_id)
341
+
342
+ def _recall(self, scope_id: str, text: str, budget: TurnBudget,
343
+ exclude: str = "") -> list[dict]:
344
+ """常规检索。**用这句话本身当线索**,不额外猜。
345
+
346
+ `exclude` 是当前这句的 trace:它不算"旧事"。
347
+ 不过滤的话,材料里会出现「相关的旧事:我是他同事,帮我看看他的账」——
348
+ 主 AI 会把它当成一件往事,而它其实是刚说的话。
349
+ """
350
+ if budget.max_recall <= 0:
351
+ return []
352
+ # 回声:当前这句话本身永远不算"旧事"。
353
+ # `/turn` 靠 exclude(trace_id)挡,`/context` 没有 trace_id 可挡——
354
+ # 它只拿得到文本,所以这里按内容再挡一次。两条路都走这个函数,
355
+ # 挡在源头比在两处各挡一次可靠。
356
+ echo = _echo_key(text)
357
+ result = self.recall.pull(
358
+ scope_id, text, limit=budget.max_recall + 1,
359
+ budget=RecallBudget(max_rounds=1,
360
+ max_candidates=budget.max_recall * 4,
361
+ max_items=budget.max_recall + 1,
362
+ max_chars=budget.max_context_chars // 2),
363
+ exclude=exclude,
364
+ )
365
+ related = [
366
+ {
367
+ "content": t["content"],
368
+ "trace_id": t["trace_id"],
369
+ "priority": t.get("priority"),
370
+ "importance": t.get("importance"),
371
+ "anchor": t.get("anchor", False),
372
+ "kind": "related",
373
+ }
374
+ for t in result.traces
375
+ if t["trace_id"] != exclude and _echo_key(t["content"]) != echo
376
+ ][: budget.max_recall]
377
+ if related:
378
+ return related
379
+
380
+ # 检索不到就退化成"最近说过的事",并且**标明它不是相关的**。
381
+ #
382
+ # 为什么不干脆留空:材料里少一段,主 AI 会以为"这个人没说过别的话",
383
+ # 而实际是"这句 query 没匹配上"。那是两种完全不同的信息。
384
+ # 也不冒充成"相关的旧事"——最近说的和相关的经常不是一回事。
385
+ return self._recent(scope_id, exclude, budget.max_recall, echo)
386
+
387
+ def _recent(self, scope_id: str, exclude: str,
388
+ limit: int, echo: str = "") -> list[dict]:
389
+ """最近说过的话。**只用来兜底,标签必须是 recent。**
390
+
391
+ `answer-%` 是澄清回答("确认"/"是这条"),不是用户说过的事。
392
+ 把它当材料喂进去,主 AI 会以为"他最近说了句确认"——
393
+ 那不是事实,是协议的一部分。
394
+ """
395
+ rows = self.engine._conn().execute(
396
+ "SELECT trace_id, content FROM traces"
397
+ " WHERE scope_id=? AND trace_id!=? AND erased_at IS NULL"
398
+ " AND source_ref NOT LIKE 'answer-%'"
399
+ " ORDER BY recorded_at DESC LIMIT ?",
400
+ (scope_id, exclude, max(limit + 4, limit)),
401
+ ).fetchall()
402
+ out: list[dict] = []
403
+ for r in rows:
404
+ if echo and _echo_key(r["content"]) == echo:
405
+ continue
406
+ out.append({"content": r["content"], "trace_id": r["trace_id"],
407
+ "priority": None, "anchor": False, "kind": "recent"})
408
+ if len(out) >= limit:
409
+ break
410
+ return out
411
+
412
+ def _apply_feelings(self, profile: dict, feelings: dict,
413
+ turn: Turn) -> None:
414
+ """按结构化事件改情绪与关系。
415
+
416
+ 缺字段就报错,不补默认值:
417
+ 补一个 `severity=1` 等于替调用方决定了"这事有多严重",
418
+ 而那个决定不该由这里做。
419
+ """
420
+ need = ("summary", "severity", "anger", "trust_delta", "reason")
421
+ missing = [k for k in need if k not in feelings]
422
+ if missing:
423
+ turn.warnings.append(f"感受事件缺少字段: {', '.join(missing)}")
424
+ return
425
+ self.mind.offended(
426
+ profile,
427
+ summary=feelings["summary"],
428
+ severity=float(feelings["severity"]),
429
+ anger=float(feelings["anger"]),
430
+ trust_delta=float(feelings["trust_delta"]),
431
+ reason=feelings["reason"],
432
+ evidence_trace_id=turn.trace_id or None,
433
+ )
434
+
435
+ def _anchored(self, scope_id: str, limit: int = 5) -> list[dict]:
436
+ """被用户要求"记住"的话。
437
+
438
+ **这一段必须无条件进材料。** 锚点在库里的意思是"不随时间淡去",
439
+ 但如果它没进上下文,主 AI 就看不到它——那和忘了没区别。
440
+
441
+ 上一版就是这样:锚点确实设了,`priority` 恒为 1.0,
442
+ 可它出现在材料里只是因为"还很新"(排在最近几句话里)。
443
+ 聊到第 20 轮,它掉出最近列表,从此消失。
444
+
445
+ 锚住却看不见,等于没锚。这一段的成本是几条短句,
446
+ 换来的是"用户明确要求的事不会丢"。
447
+ """
448
+ from .decay import Lifecycle
449
+
450
+ out: list[dict] = []
451
+ for item in Lifecycle(self.engine).anchored(scope_id):
452
+ trace = self.engine.get_trace(item["ref_id"])
453
+ if not trace:
454
+ continue
455
+ text = trace["content"]
456
+ # 「记住,」这个前缀是指令,念给主 AI 听没有意义。
457
+ body = detect.remember_body(text) or text
458
+ out.append({
459
+ "content": body,
460
+ "raw": text,
461
+ "trace_id": item["ref_id"],
462
+ "priority": item.get("priority"),
463
+ })
464
+ if len(out) >= limit:
465
+ break
466
+ return out
467
+
468
+ SUSPECT_TTL = 1800
469
+
470
+ def _settle_suspicion(self, scope_id: str, account_id: str, turn: Turn,
471
+ result) -> None:
472
+ from datetime import datetime, timezone
473
+ profile = self.profiles.get(scope_id, account_id)
474
+ if not profile or profile.get("status") != "suspect":
475
+ return
476
+ alerts = self.profiles.open_alerts(scope_id, account_id)
477
+ renamed = any(
478
+ o.interpretation.relation == "nickname" and o.decision.outcome == "commit"
479
+ for o in result.outcomes
480
+ )
481
+ only_address = bool(alerts) and all(a["kind"] == "address_shift" for a in alerts)
482
+ expired = False
483
+ try:
484
+ since = datetime.fromisoformat(str(profile.get("updated_at")))
485
+ now = datetime.fromisoformat(self.engine.now())
486
+ if since.tzinfo is None:
487
+ since = since.replace(tzinfo=timezone.utc)
488
+ if now.tzinfo is None:
489
+ now = now.replace(tzinfo=timezone.utc)
490
+ expired = (now - since).total_seconds() > self.SUSPECT_TTL
491
+ except (TypeError, ValueError):
492
+ expired = False
493
+ if (renamed and only_address) or expired or not alerts:
494
+ reason = "用户明确改了称呼" if renamed else "嫌疑超时自动解除"
495
+ for a in alerts:
496
+ self.profiles.evaluate(a["alert_id"], "same_person", [reason],
497
+ actor_id="system")
498
+ self.profiles.restore(scope_id, account_id)
499
+ turn.alerts = []
500
+
501
+ def _debt_lines(self, scope_id: str, account_id: str, limit: int = 6) -> list[str]:
502
+ import json as _json
503
+ conn = self.engine._conn()
504
+ rows = conn.execute(
505
+ "SELECT state_key, value_json FROM states WHERE scope_id=?"
506
+ " AND status='known' AND state_key LIKE ? LIMIT 50",
507
+ (scope_id, f"{scope_id}|%|debt|%"),
508
+ ).fetchall()
509
+ lines: list[str] = []
510
+ for r in rows:
511
+ parts = r["state_key"].split("|")
512
+ if len(parts) < 4:
513
+ continue
514
+ debtor, creditor = parts[1], parts[3]
515
+ try:
516
+ value = _json.loads(r["value_json"]) if r["value_json"] else None
517
+ except ValueError:
518
+ value = None
519
+ if not isinstance(value, (int, float)) or value == 0:
520
+ continue
521
+ ev = conn.execute(
522
+ "SELECT payload_json FROM events WHERE state_key=? AND void=0"
523
+ " AND payload_json LIKE '%\"unit\":\"%'"
524
+ " ORDER BY sequence_no DESC LIMIT 1",
525
+ (r["state_key"],),
526
+ ).fetchone()
527
+ unit = ""
528
+ if ev and ev["payload_json"]:
529
+ try:
530
+ unit = str(_json.loads(ev["payload_json"]).get("unit") or "")
531
+ except (ValueError, AttributeError):
532
+ unit = ""
533
+ who = {"assistant": "你(助手)", "peer": "你(助手)"}.get(creditor, creditor)
534
+ amount = f"{value:g}{unit}"
535
+ if debtor == account_id:
536
+ lines.append(f"- 他欠{who} {amount}")
537
+ else:
538
+ lines.append(f"- {debtor} 欠{who} {amount}")
539
+ if len(lines) >= limit:
540
+ break
541
+ return lines
542
+
543
+ def _clarification_quote(self, row: dict, limit: int = 40) -> str:
544
+ """澄清对应的那句原话(截短)。找不到就返回空串。"""
545
+ import json as _json
546
+ try:
547
+ ids = _json.loads(row.get("interpretation_ids_json") or "[]")
548
+ except ValueError:
549
+ return ""
550
+ if not ids:
551
+ return ""
552
+ got = self.engine._conn().execute(
553
+ "SELECT t.content FROM interpretations i JOIN traces t"
554
+ " ON t.trace_id = i.trace_id WHERE i.interpretation_id=? LIMIT 1",
555
+ (ids[0],),
556
+ ).fetchone()
557
+ if not got:
558
+ return ""
559
+ text = " ".join(str(got["content"]).split())
560
+ return text if len(text) <= limit else text[: limit - 1] + "…"
561
+
562
+ # ---------------------------------------------------------- 组装
563
+ def _compose(self, scope_id: str, account_id: str, profile: dict,
564
+ turn: Turn, budget: TurnBudget, *, mark: bool = True,
565
+ session: str | None = None) -> str:
566
+ """把材料拼成给主 AI 的一段话。
567
+
568
+ 分四段,因为这个顺序就是主 AI 该有的思考顺序:
569
+ 先知道对面是谁,再知道自己什么状态,再看相关的旧事,
570
+ 最后才是"有没有要留意的"。
571
+ """
572
+ blocks: list[str] = []
573
+
574
+ # 1. 这个人
575
+ card = self.profiles.render(scope_id, account_id)
576
+ blocks.append("## 关于这个人\n" + card)
577
+
578
+ # 2. 用户要求记住的事。**排在画像之后、状态之前**——
579
+ # 它比"我现在什么心情"更该先被看见。
580
+ if turn.anchored:
581
+ items = "\n".join(f"- {t['content']}" for t in turn.anchored)
582
+ blocks.append(
583
+ "## 重要的事(他专门说过要记住,不会随时间淡去)\n" + items)
584
+
585
+ # 2.5 账和约定(v0.52):已经入账的欠款直接给出余额。
586
+ # 以前账只在「相关的旧事」里以原话出现,余额要模型自己算,
587
+ # 还经常因为检索没命中而整段消失。
588
+ debts = self._debt_lines(scope_id, account_id)
589
+ if debts:
590
+ blocks.append("## 账目(已确认)\n" + "\n".join(debts))
591
+
592
+ # 3. 我现在的状态
593
+ if self.mind is not None:
594
+ blocks.append("## 我现在的状态\n" + self.mind.render(profile))
595
+
596
+ # 4. 相关的旧事 / 最近说过的话
597
+ #
598
+ # 两段分开写。混在一起,主 AI 会把"最近说的"当成"与本题相关的",
599
+ # 于是拿一句无关的旧话去解释眼前的事。
600
+ if turn.recalled:
601
+ related = [t for t in turn.recalled if t.get("kind") == "related"]
602
+ recent = [t for t in turn.recalled if t.get("kind") == "recent"]
603
+ if related:
604
+ def _tag(t: dict) -> str:
605
+ if t.get("anchor"):
606
+ return "(重点)"
607
+ if float(t.get("importance") or 0) >= 3.0:
608
+ return "(重要)"
609
+ return ""
610
+
611
+ items = "\n".join(
612
+ f"- {t['content']}{_tag(t)}" for t in related
613
+ )
614
+ blocks.append("## 相关的旧事\n" + items)
615
+ if recent:
616
+ items = "\n".join(f"- {t['content']}" for t in recent)
617
+ blocks.append("## 最近说过的话(不是检索命中的,仅供参考)\n"
618
+ + items)
619
+
620
+ # 5. 还没确定的事
621
+ #
622
+ # 和「要留意的」分开:那段说的是**这一轮**的问题,
623
+ # 这段说的是**跨轮悬着**的问题。
624
+ # 混在一起,模型会以为"这句话需要问",而实际是"三天前那句还没定"。
625
+ #
626
+ # 宿主形态下这一步是必需的:DSH 没有"点确认"的交互,
627
+ # 澄清开出来就永远悬着——问过却不再提,等于没问。
628
+ #
629
+ # ## 但必须熔断
630
+ #
631
+ # 每轮都念一遍「有件事还没确定」,用户会被同一句话连问五遍。
632
+ # 那不是澄清,是审问。三道闸:
633
+ #
634
+ # 1. 每条只念一次 —— 念过就标 asked,不再进材料
635
+ # 2. 一轮最多提几条 —— budget.max_questions
636
+ # 3. 累计问够就不再新开 —— budget.max_asked_total
637
+ #
638
+ # 熔断之后材料里**只剩一句总数**,不说"去问一句"。
639
+ # 用户主动提起来时,那些记录还在库里,不会丢。
640
+ unasked = self.ingest.unasked_clarifications(scope_id)
641
+ asked_total = self.ingest.asked_count(scope_id, session)
642
+ if unasked and asked_total < budget.max_asked_total:
643
+ shown = unasked[: budget.max_questions]
644
+ # v0.52:带上原话。只写「缺一个关键信息」,主模型不知道是哪句话,
645
+ # 也就无从问起。
646
+ lines = []
647
+ for p in shown:
648
+ label = _CLARIFICATION_LABELS.get(p["kind"], "有一处还没说清楚")
649
+ quote = self._clarification_quote(p)
650
+ lines.append(f"- 「{quote}」:{label}" if quote else "- " + label)
651
+ # 「当前这句」而不是「下面这句」:这段文字进的是 systemPrompt,
652
+ # 用户那句话在另一条消息里,不在这段下面。
653
+ hint = ("\n(如果用户当前这句其实已经说清楚了,就当它确认了;"
654
+ "否则**最多挑一件**顺带问一句,别追问)")
655
+ tag = "" if len(unasked) <= len(shown) else f",另有 {len(unasked) - len(shown)} 件"
656
+ blocks.append(
657
+ "## 还没确定的事" + tag + "\n" + "\n".join(lines) + hint
658
+ )
659
+ # 只读调用(`/context`)不标记:读一次材料不等于问过用户。
660
+ if mark:
661
+ self.ingest.mark_asked([p["request_id"] for p in shown],
662
+ session)
663
+ elif unasked:
664
+ # 熔断:问够了。只报个数,不再引导追问。
665
+ blocks.append(
666
+ f"## 还没确定的事\n- 有 {len(unasked)} 处还没确认"
667
+ "(已经问过几次了,别主动追问;他主动提起来再说)"
668
+ )
669
+
670
+ # 6. 要留意的
671
+ notes: list[str] = []
672
+ if turn.assessment is not None:
673
+ notes.append(turn.assessment.render())
674
+ if turn.clarifications:
675
+ notes.append(f"这句话还没确认清楚,需要问一句"
676
+ f"({len(turn.clarifications)} 处)")
677
+ if turn.warnings:
678
+ notes.extend(turn.warnings)
679
+ if notes:
680
+ blocks.append("## 要留意的\n" + "\n".join(notes))
681
+
682
+ text = "\n\n".join(blocks)
683
+ if len(text) > budget.max_context_chars:
684
+ # 截断要留痕,不能悄悄切掉——主 AI 不知道被截了,
685
+ # 就会以为自己看到的是全部。
686
+ text = text[: budget.max_context_chars]
687
+ turn.warnings.append(
688
+ f"材料超过 {budget.max_context_chars} 字,已截断",
689
+ )
690
+ return text