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,292 +1,292 @@
|
|
|
1
|
-
"""把澄清请求渲染成人话——角色口吻,不是表单。
|
|
2
|
-
|
|
3
|
-
内核只产出结构化数据(relation / semantic_op / slots),
|
|
4
|
-
**措辞在这里**。这样角色设定(叫"主人"还是"你"、自称"鲸鲸"还是"我")
|
|
5
|
-
不会污染内核,换个 persona 就能换一种说话方式。
|
|
6
|
-
|
|
7
|
-
persona = Persona(address="主人", names={"kitty": "鲸鲸", "rice": "大米饭"})
|
|
8
|
-
ask(request, persona)
|
|
9
|
-
→ 主人,你说的给鲸鲸 40 碗大米饭,是真的吗?
|
|
10
|
-
|
|
11
|
-
为什么不把话术写进内核:
|
|
12
|
-
内核的测试全在断言状态机,不该因为改了一句台词而变红。
|
|
13
|
-
反过来,话术也不该因为改了一个状态机分支而重新审视。
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
from dataclasses import dataclass, field
|
|
19
|
-
from typing import Any
|
|
20
|
-
|
|
21
|
-
from .due import describe as describe_due
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class Persona:
|
|
26
|
-
"""角色设定。全部有默认值,什么都不配也能用。"""
|
|
27
|
-
|
|
28
|
-
address: str = "你" # 怎么称呼用户
|
|
29
|
-
names: dict[str, str] = field(default_factory=dict) # 实体 → 显示名
|
|
30
|
-
|
|
31
|
-
def name_of(self, value: Any, default: str = "?") -> str:
|
|
32
|
-
if value in (None, ""):
|
|
33
|
-
return default
|
|
34
|
-
return self.names.get(str(value), str(value))
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# 槽位的中文说法。缺槽位时会问「主人,{东西}是多少呀?」
|
|
38
|
-
_SLOT_LABEL = {
|
|
39
|
-
"quantity": "数量",
|
|
40
|
-
"unit": "单位",
|
|
41
|
-
"item": "是什么东西",
|
|
42
|
-
"debtor": "是谁欠的",
|
|
43
|
-
"creditor": "欠谁的",
|
|
44
|
-
"subject": "说的是谁",
|
|
45
|
-
"object": "说的是什么",
|
|
46
|
-
"value": "具体是什么",
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def describe(detail: dict[str, Any], persona: Persona) -> str:
|
|
51
|
-
"""把一条解释渲染成短语:「给鲸鲸 40 碗大米饭」。"""
|
|
52
|
-
slots = detail.get("slots") or {}
|
|
53
|
-
relation = detail.get("relation") or ""
|
|
54
|
-
op = detail.get("semantic_op")
|
|
55
|
-
|
|
56
|
-
if relation == "debt":
|
|
57
|
-
who = persona.name_of(slots.get("creditor"), "对方")
|
|
58
|
-
item = persona.name_of(slots.get("item"), "")
|
|
59
|
-
qty = _quantity(slots, persona)
|
|
60
|
-
body = f"{qty}{item}".strip()
|
|
61
|
-
due = slots.get("due_hint")
|
|
62
|
-
# 承诺要带上时间,否则用户听到的「还 2 袋」和已经还了没区别。
|
|
63
|
-
# 「下个月要还给鲸鲸的 2 袋」——一听就知道还没发生。
|
|
64
|
-
promised = f"{due}要还给{who}的 {body}" if due else f"要还给{who}的 {body}"
|
|
65
|
-
return {
|
|
66
|
-
"repay": f"还给{who}的 {body}",
|
|
67
|
-
"promise": promised,
|
|
68
|
-
"waive": f"免掉给{who}的 {body}",
|
|
69
|
-
"set_total": f"欠{who}的一共 {body}",
|
|
70
|
-
"correct": f"改成 {body}",
|
|
71
|
-
"retract": "刚才那条不算",
|
|
72
|
-
None: f"给{who} {body}",
|
|
73
|
-
}.get(op, f"给{who} {body}")
|
|
74
|
-
|
|
75
|
-
if relation == "nickname":
|
|
76
|
-
return f"叫你「{slots.get('value', '')}」"
|
|
77
|
-
|
|
78
|
-
if relation == "preference":
|
|
79
|
-
obj = slots.get("object") or slots.get("item") or ""
|
|
80
|
-
return f"{obj}这件事" if obj else "这件事"
|
|
81
|
-
|
|
82
|
-
item = persona.name_of(slots.get("item"), "")
|
|
83
|
-
qty = _quantity(slots, persona)
|
|
84
|
-
return f"{qty}{item}".strip() or relation or "这件事"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _quantity(slots: dict[str, Any], persona: Persona) -> str:
|
|
88
|
-
q = slots.get("quantity")
|
|
89
|
-
if q is None:
|
|
90
|
-
return ""
|
|
91
|
-
unit = persona.name_of(slots.get("unit"), "")
|
|
92
|
-
return f"{q}{unit}"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def ask(detail: dict[str, Any], persona: Persona | None = None) -> str:
|
|
96
|
-
"""把一条澄清请求渲染成一句人话。
|
|
97
|
-
|
|
98
|
-
四种 kind 各有各的问法,但都不出现"确认/取消"这种界面词——
|
|
99
|
-
**确认应该藏在对话里,不该变成一次表单提交。**
|
|
100
|
-
"""
|
|
101
|
-
p = persona or Persona()
|
|
102
|
-
kind = detail.get("kind") or "unconfirmed"
|
|
103
|
-
|
|
104
|
-
if kind == "ambiguous_candidates":
|
|
105
|
-
names = [
|
|
106
|
-
_relation_label(c.get("relation"), c.get("semantic_op"))
|
|
107
|
-
for c in detail.get("candidates", [])
|
|
108
|
-
]
|
|
109
|
-
names = [n for n in names if n]
|
|
110
|
-
if len(names) >= 2:
|
|
111
|
-
return f"{p.address},你是在说{names[0]},还是{names[1]}呀?"
|
|
112
|
-
return f"{p.address},这句我有点拿不准,你能再说清楚一点吗?"
|
|
113
|
-
|
|
114
|
-
if kind == "balance_check":
|
|
115
|
-
said = detail.get("said")
|
|
116
|
-
actual = detail.get("actual")
|
|
117
|
-
unit = detail.get("unit") or ""
|
|
118
|
-
said_t = f"{said:g}" if isinstance(said, (int, float)) else str(said or "?")
|
|
119
|
-
actual_t = f"{actual:g}" if isinstance(actual, (int, float)) else str(actual or "?")
|
|
120
|
-
return (
|
|
121
|
-
f"{p.address},你说还剩 {said_t}{unit},"
|
|
122
|
-
f"可我记的是 {actual_t}{unit}。哪个对?"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
if kind == "settled_gap":
|
|
126
|
-
# 「你说还清了,可账上还差 3」——不指责,只把两个说法摆在一起。
|
|
127
|
-
qty = detail.get("outstanding")
|
|
128
|
-
qty_text = f"{qty:g}" if isinstance(qty, (int, float)) else str(qty or "")
|
|
129
|
-
unit = detail.get("unit") or ""
|
|
130
|
-
return (
|
|
131
|
-
f"{p.address},你说还清了,可账上还差 {qty_text}{unit}。"
|
|
132
|
-
f"是还有一笔没记上吗?"
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
if kind == "total_reading":
|
|
136
|
-
# 两个数都念出来。少念一个,用户就是在猜我指的是哪一个。
|
|
137
|
-
gross = detail.get("gross")
|
|
138
|
-
remaining = detail.get("remaining")
|
|
139
|
-
repaid = detail.get("repaid")
|
|
140
|
-
g = f"{gross:g}" if isinstance(gross, (int, float)) else "?"
|
|
141
|
-
r = f"{remaining:g}" if isinstance(remaining, (int, float)) else "?"
|
|
142
|
-
p_ = f"{repaid:g}" if isinstance(repaid, (int, float)) else "?"
|
|
143
|
-
return (
|
|
144
|
-
f"{p.address},你说的 {g},是现在还欠 {g},"
|
|
145
|
-
f"还是当初一共欠 {g}?"
|
|
146
|
-
f"(已经还过 {p_},如果是后者,那就还剩 {r})"
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
if kind == "total_overrides":
|
|
150
|
-
n = detail.get("erased_count") or 0
|
|
151
|
-
total = detail.get("erased_total")
|
|
152
|
-
total_text = f"{total:g}" if isinstance(total, (int, float)) else "若干"
|
|
153
|
-
return (
|
|
154
|
-
f"{p.address},这么改会把之前 {n} 笔还款(共 {total_text})一起作废,"
|
|
155
|
-
f"你确定吗?"
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
if kind == "unresolvable_target":
|
|
159
|
-
return f"{p.address},你说的「不是 / 不算」,我这边没找到对应的那一笔,是指哪一次?"
|
|
160
|
-
|
|
161
|
-
if kind == "missing_slot":
|
|
162
|
-
missing = detail.get("missing") or []
|
|
163
|
-
labels = [_SLOT_LABEL.get(m, m) for m in missing]
|
|
164
|
-
what = "和".join(labels) if labels else "细节"
|
|
165
|
-
return f"{p.address},{what}是多少呀?"
|
|
166
|
-
|
|
167
|
-
# unconfirmed:最常见的一种
|
|
168
|
-
body = describe(detail, p)
|
|
169
|
-
return f"{p.address},你说的{body},是真的吗?"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _relation_label(relation: str | None, op: str | None) -> str:
|
|
173
|
-
return {
|
|
174
|
-
"debt": "欠账的事",
|
|
175
|
-
"nickname": "称呼的事",
|
|
176
|
-
"preference": "喜好",
|
|
177
|
-
}.get(relation or "", "")
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def acknowledge(detail: dict[str, Any], persona: Persona | None = None) -> str:
|
|
181
|
-
"""确认之后的一句话。不是"已提交",是"记下了"。"""
|
|
182
|
-
p = persona or Persona()
|
|
183
|
-
body = describe(detail, p)
|
|
184
|
-
if detail.get("semantic_op") == "promise":
|
|
185
|
-
# 「记下了,下个月要还给鲸鲸的 2 袋」——这句必须听起来像"记了一笔
|
|
186
|
-
# 待办",而不是"账已结清"。中文里"记下了"两者都能接,
|
|
187
|
-
# 所以补一句时间,把语义钉死。
|
|
188
|
-
return f"记下了,{body},到日子我提醒你。"
|
|
189
|
-
if not body or body == "刚才那条不算":
|
|
190
|
-
return "嗯,记下了。"
|
|
191
|
-
return f"记下了,{body}。"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def declined(persona: Persona | None = None) -> str:
|
|
195
|
-
"""用户否认之后的一句话。不追问,等他自己说。"""
|
|
196
|
-
p = persona or Persona()
|
|
197
|
-
return f"好,那这条先不算。{p.address}你再说一次?"
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
# ============================================================ 提醒
|
|
201
|
-
def _split_key(state_key: str) -> tuple[str, str]:
|
|
202
|
-
"""从 state_key 里取债主和标的。
|
|
203
|
-
|
|
204
|
-
`sc_xxx|player|debt|kitty|item=rice` → ("kitty", "rice")
|
|
205
|
-
取不出来就给空——提醒宁可少一个名字,也不能因为解析失败而崩掉整轮对话。
|
|
206
|
-
"""
|
|
207
|
-
parts = (state_key or "").split("|")
|
|
208
|
-
creditor = parts[3] if len(parts) > 3 and parts[3] != "-" else ""
|
|
209
|
-
item = ""
|
|
210
|
-
if len(parts) > 4 and parts[4].startswith("item="):
|
|
211
|
-
item = parts[4][5:]
|
|
212
|
-
return creditor, item
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def remind(item: dict[str, Any], persona: Persona | None = None) -> str:
|
|
216
|
-
"""一句到期提醒。
|
|
217
|
-
|
|
218
|
-
**不出现"任务/待办/逾期"这类词**。承诺是人和人之间的事,
|
|
219
|
-
不是工单系统。所以句型跟他当初说的那句话尽量贴近:
|
|
220
|
-
「你说月底还我两袋」,而不是「您有一条逾期记录」。
|
|
221
|
-
"""
|
|
222
|
-
p = persona or Persona()
|
|
223
|
-
creditor, item_name = _split_key(item.get("state_key", ""))
|
|
224
|
-
who = p.name_of(creditor, "你")
|
|
225
|
-
thing = p.name_of(item_name, "")
|
|
226
|
-
|
|
227
|
-
qty = item.get("remaining")
|
|
228
|
-
if qty in (None, 0):
|
|
229
|
-
qty = item.get("quantity")
|
|
230
|
-
qty_text = f"{qty:g}" if isinstance(qty, (int, float)) else str(qty or "")
|
|
231
|
-
unit = item.get("unit") or ""
|
|
232
|
-
body = f"{qty_text}{unit}{thing}".strip()
|
|
233
|
-
|
|
234
|
-
when = describe_due(item.get("due_at"))
|
|
235
|
-
if item.get("due_at"):
|
|
236
|
-
return f"{p.address},你说{when}要还{who} {body},今天到期了。"
|
|
237
|
-
return f"{p.address},你答应过还{who} {body},还没还呢。"
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
# ============================================================ 查账的答案
|
|
241
|
-
def _conflict(reason: str | None) -> str:
|
|
242
|
-
"""把内核的冲突原因翻成人话。
|
|
243
|
-
|
|
244
|
-
只有真正见过的几种才翻。翻了不认识的,就把原文念出来——
|
|
245
|
-
**宁可说得笨,不可说得像懂了。**
|
|
246
|
-
"""
|
|
247
|
-
if not reason:
|
|
248
|
-
return "有个地方对不上"
|
|
249
|
-
if reason.startswith("unit_mismatch:"):
|
|
250
|
-
_, _, pair = reason.partition(":")
|
|
251
|
-
left, _, right = pair.partition("!=")
|
|
252
|
-
return f"前面记的是「{left}」,这次说的是「{right}」,两个单位我不敢直接加减"
|
|
253
|
-
if reason.startswith("non_negative_violated"):
|
|
254
|
-
return "这么算会变成负数"
|
|
255
|
-
return reason
|
|
256
|
-
|
|
257
|
-
def answer(detail: dict[str, Any], persona: Persona | None = None) -> str:
|
|
258
|
-
"""「还剩几捆?」/「我一共欠多少?」——把账上的数念出来。
|
|
259
|
-
|
|
260
|
-
三种答案**必须是三句不同的话**,不能合并成一句"没有":
|
|
261
|
-
|
|
262
|
-
known 3 捆
|
|
263
|
-
unknown 记过,但依据被撤了 —— 不是 0,是"现在说不准"
|
|
264
|
-
not_found 从来没记过 —— 不是"你没欠",是"你没跟我说过"
|
|
265
|
-
|
|
266
|
-
把后两种都说成「没有」,就是把"系统的问题"和"事实的问题"
|
|
267
|
-
讲成同一件事。而这套系统存在的全部理由,就是分得清这两者。
|
|
268
|
-
"""
|
|
269
|
-
p = persona or Persona()
|
|
270
|
-
status = detail.get("status")
|
|
271
|
-
creditor, item_from_key = _split_key(detail.get("state_key") or "")
|
|
272
|
-
|
|
273
|
-
if status == "not_found":
|
|
274
|
-
return f"{p.address},这件事你没跟我说过,我这儿没记着。"
|
|
275
|
-
|
|
276
|
-
if status == "disputed":
|
|
277
|
-
return f"{p.address},这笔账我这儿对不上({_conflict(detail.get('reason'))}),不敢报数。"
|
|
278
|
-
|
|
279
|
-
if status == "unknown":
|
|
280
|
-
return (
|
|
281
|
-
f"{p.address},这笔我记过,"
|
|
282
|
-
f"但当时那条依据后来被撤了,现在说不准。"
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
value = detail.get("value")
|
|
286
|
-
unit = p.name_of(detail.get("unit"), "")
|
|
287
|
-
item = p.name_of(detail.get("item") or item_from_key, "")
|
|
288
|
-
who = p.name_of(detail.get("creditor") or creditor, "对方")
|
|
289
|
-
value_t = f"{value:g}" if isinstance(value, (int, float)) else str(value)
|
|
290
|
-
|
|
291
|
-
body = f"{value_t} {unit}{item}".strip()
|
|
292
|
-
return f"{p.address},还欠{who} {body}。"
|
|
1
|
+
"""把澄清请求渲染成人话——角色口吻,不是表单。
|
|
2
|
+
|
|
3
|
+
内核只产出结构化数据(relation / semantic_op / slots),
|
|
4
|
+
**措辞在这里**。这样角色设定(叫"主人"还是"你"、自称"鲸鲸"还是"我")
|
|
5
|
+
不会污染内核,换个 persona 就能换一种说话方式。
|
|
6
|
+
|
|
7
|
+
persona = Persona(address="主人", names={"kitty": "鲸鲸", "rice": "大米饭"})
|
|
8
|
+
ask(request, persona)
|
|
9
|
+
→ 主人,你说的给鲸鲸 40 碗大米饭,是真的吗?
|
|
10
|
+
|
|
11
|
+
为什么不把话术写进内核:
|
|
12
|
+
内核的测试全在断言状态机,不该因为改了一句台词而变红。
|
|
13
|
+
反过来,话术也不该因为改了一个状态机分支而重新审视。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .due import describe as describe_due
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Persona:
|
|
26
|
+
"""角色设定。全部有默认值,什么都不配也能用。"""
|
|
27
|
+
|
|
28
|
+
address: str = "你" # 怎么称呼用户
|
|
29
|
+
names: dict[str, str] = field(default_factory=dict) # 实体 → 显示名
|
|
30
|
+
|
|
31
|
+
def name_of(self, value: Any, default: str = "?") -> str:
|
|
32
|
+
if value in (None, ""):
|
|
33
|
+
return default
|
|
34
|
+
return self.names.get(str(value), str(value))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# 槽位的中文说法。缺槽位时会问「主人,{东西}是多少呀?」
|
|
38
|
+
_SLOT_LABEL = {
|
|
39
|
+
"quantity": "数量",
|
|
40
|
+
"unit": "单位",
|
|
41
|
+
"item": "是什么东西",
|
|
42
|
+
"debtor": "是谁欠的",
|
|
43
|
+
"creditor": "欠谁的",
|
|
44
|
+
"subject": "说的是谁",
|
|
45
|
+
"object": "说的是什么",
|
|
46
|
+
"value": "具体是什么",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def describe(detail: dict[str, Any], persona: Persona) -> str:
|
|
51
|
+
"""把一条解释渲染成短语:「给鲸鲸 40 碗大米饭」。"""
|
|
52
|
+
slots = detail.get("slots") or {}
|
|
53
|
+
relation = detail.get("relation") or ""
|
|
54
|
+
op = detail.get("semantic_op")
|
|
55
|
+
|
|
56
|
+
if relation == "debt":
|
|
57
|
+
who = persona.name_of(slots.get("creditor"), "对方")
|
|
58
|
+
item = persona.name_of(slots.get("item"), "")
|
|
59
|
+
qty = _quantity(slots, persona)
|
|
60
|
+
body = f"{qty}{item}".strip()
|
|
61
|
+
due = slots.get("due_hint")
|
|
62
|
+
# 承诺要带上时间,否则用户听到的「还 2 袋」和已经还了没区别。
|
|
63
|
+
# 「下个月要还给鲸鲸的 2 袋」——一听就知道还没发生。
|
|
64
|
+
promised = f"{due}要还给{who}的 {body}" if due else f"要还给{who}的 {body}"
|
|
65
|
+
return {
|
|
66
|
+
"repay": f"还给{who}的 {body}",
|
|
67
|
+
"promise": promised,
|
|
68
|
+
"waive": f"免掉给{who}的 {body}",
|
|
69
|
+
"set_total": f"欠{who}的一共 {body}",
|
|
70
|
+
"correct": f"改成 {body}",
|
|
71
|
+
"retract": "刚才那条不算",
|
|
72
|
+
None: f"给{who} {body}",
|
|
73
|
+
}.get(op, f"给{who} {body}")
|
|
74
|
+
|
|
75
|
+
if relation == "nickname":
|
|
76
|
+
return f"叫你「{slots.get('value', '')}」"
|
|
77
|
+
|
|
78
|
+
if relation == "preference":
|
|
79
|
+
obj = slots.get("object") or slots.get("item") or ""
|
|
80
|
+
return f"{obj}这件事" if obj else "这件事"
|
|
81
|
+
|
|
82
|
+
item = persona.name_of(slots.get("item"), "")
|
|
83
|
+
qty = _quantity(slots, persona)
|
|
84
|
+
return f"{qty}{item}".strip() or relation or "这件事"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _quantity(slots: dict[str, Any], persona: Persona) -> str:
|
|
88
|
+
q = slots.get("quantity")
|
|
89
|
+
if q is None:
|
|
90
|
+
return ""
|
|
91
|
+
unit = persona.name_of(slots.get("unit"), "")
|
|
92
|
+
return f"{q}{unit}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ask(detail: dict[str, Any], persona: Persona | None = None) -> str:
|
|
96
|
+
"""把一条澄清请求渲染成一句人话。
|
|
97
|
+
|
|
98
|
+
四种 kind 各有各的问法,但都不出现"确认/取消"这种界面词——
|
|
99
|
+
**确认应该藏在对话里,不该变成一次表单提交。**
|
|
100
|
+
"""
|
|
101
|
+
p = persona or Persona()
|
|
102
|
+
kind = detail.get("kind") or "unconfirmed"
|
|
103
|
+
|
|
104
|
+
if kind == "ambiguous_candidates":
|
|
105
|
+
names = [
|
|
106
|
+
_relation_label(c.get("relation"), c.get("semantic_op"))
|
|
107
|
+
for c in detail.get("candidates", [])
|
|
108
|
+
]
|
|
109
|
+
names = [n for n in names if n]
|
|
110
|
+
if len(names) >= 2:
|
|
111
|
+
return f"{p.address},你是在说{names[0]},还是{names[1]}呀?"
|
|
112
|
+
return f"{p.address},这句我有点拿不准,你能再说清楚一点吗?"
|
|
113
|
+
|
|
114
|
+
if kind == "balance_check":
|
|
115
|
+
said = detail.get("said")
|
|
116
|
+
actual = detail.get("actual")
|
|
117
|
+
unit = detail.get("unit") or ""
|
|
118
|
+
said_t = f"{said:g}" if isinstance(said, (int, float)) else str(said or "?")
|
|
119
|
+
actual_t = f"{actual:g}" if isinstance(actual, (int, float)) else str(actual or "?")
|
|
120
|
+
return (
|
|
121
|
+
f"{p.address},你说还剩 {said_t}{unit},"
|
|
122
|
+
f"可我记的是 {actual_t}{unit}。哪个对?"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if kind == "settled_gap":
|
|
126
|
+
# 「你说还清了,可账上还差 3」——不指责,只把两个说法摆在一起。
|
|
127
|
+
qty = detail.get("outstanding")
|
|
128
|
+
qty_text = f"{qty:g}" if isinstance(qty, (int, float)) else str(qty or "")
|
|
129
|
+
unit = detail.get("unit") or ""
|
|
130
|
+
return (
|
|
131
|
+
f"{p.address},你说还清了,可账上还差 {qty_text}{unit}。"
|
|
132
|
+
f"是还有一笔没记上吗?"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if kind == "total_reading":
|
|
136
|
+
# 两个数都念出来。少念一个,用户就是在猜我指的是哪一个。
|
|
137
|
+
gross = detail.get("gross")
|
|
138
|
+
remaining = detail.get("remaining")
|
|
139
|
+
repaid = detail.get("repaid")
|
|
140
|
+
g = f"{gross:g}" if isinstance(gross, (int, float)) else "?"
|
|
141
|
+
r = f"{remaining:g}" if isinstance(remaining, (int, float)) else "?"
|
|
142
|
+
p_ = f"{repaid:g}" if isinstance(repaid, (int, float)) else "?"
|
|
143
|
+
return (
|
|
144
|
+
f"{p.address},你说的 {g},是现在还欠 {g},"
|
|
145
|
+
f"还是当初一共欠 {g}?"
|
|
146
|
+
f"(已经还过 {p_},如果是后者,那就还剩 {r})"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if kind == "total_overrides":
|
|
150
|
+
n = detail.get("erased_count") or 0
|
|
151
|
+
total = detail.get("erased_total")
|
|
152
|
+
total_text = f"{total:g}" if isinstance(total, (int, float)) else "若干"
|
|
153
|
+
return (
|
|
154
|
+
f"{p.address},这么改会把之前 {n} 笔还款(共 {total_text})一起作废,"
|
|
155
|
+
f"你确定吗?"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if kind == "unresolvable_target":
|
|
159
|
+
return f"{p.address},你说的「不是 / 不算」,我这边没找到对应的那一笔,是指哪一次?"
|
|
160
|
+
|
|
161
|
+
if kind == "missing_slot":
|
|
162
|
+
missing = detail.get("missing") or []
|
|
163
|
+
labels = [_SLOT_LABEL.get(m, m) for m in missing]
|
|
164
|
+
what = "和".join(labels) if labels else "细节"
|
|
165
|
+
return f"{p.address},{what}是多少呀?"
|
|
166
|
+
|
|
167
|
+
# unconfirmed:最常见的一种
|
|
168
|
+
body = describe(detail, p)
|
|
169
|
+
return f"{p.address},你说的{body},是真的吗?"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _relation_label(relation: str | None, op: str | None) -> str:
|
|
173
|
+
return {
|
|
174
|
+
"debt": "欠账的事",
|
|
175
|
+
"nickname": "称呼的事",
|
|
176
|
+
"preference": "喜好",
|
|
177
|
+
}.get(relation or "", "")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def acknowledge(detail: dict[str, Any], persona: Persona | None = None) -> str:
|
|
181
|
+
"""确认之后的一句话。不是"已提交",是"记下了"。"""
|
|
182
|
+
p = persona or Persona()
|
|
183
|
+
body = describe(detail, p)
|
|
184
|
+
if detail.get("semantic_op") == "promise":
|
|
185
|
+
# 「记下了,下个月要还给鲸鲸的 2 袋」——这句必须听起来像"记了一笔
|
|
186
|
+
# 待办",而不是"账已结清"。中文里"记下了"两者都能接,
|
|
187
|
+
# 所以补一句时间,把语义钉死。
|
|
188
|
+
return f"记下了,{body},到日子我提醒你。"
|
|
189
|
+
if not body or body == "刚才那条不算":
|
|
190
|
+
return "嗯,记下了。"
|
|
191
|
+
return f"记下了,{body}。"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def declined(persona: Persona | None = None) -> str:
|
|
195
|
+
"""用户否认之后的一句话。不追问,等他自己说。"""
|
|
196
|
+
p = persona or Persona()
|
|
197
|
+
return f"好,那这条先不算。{p.address}你再说一次?"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ============================================================ 提醒
|
|
201
|
+
def _split_key(state_key: str) -> tuple[str, str]:
|
|
202
|
+
"""从 state_key 里取债主和标的。
|
|
203
|
+
|
|
204
|
+
`sc_xxx|player|debt|kitty|item=rice` → ("kitty", "rice")
|
|
205
|
+
取不出来就给空——提醒宁可少一个名字,也不能因为解析失败而崩掉整轮对话。
|
|
206
|
+
"""
|
|
207
|
+
parts = (state_key or "").split("|")
|
|
208
|
+
creditor = parts[3] if len(parts) > 3 and parts[3] != "-" else ""
|
|
209
|
+
item = ""
|
|
210
|
+
if len(parts) > 4 and parts[4].startswith("item="):
|
|
211
|
+
item = parts[4][5:]
|
|
212
|
+
return creditor, item
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def remind(item: dict[str, Any], persona: Persona | None = None) -> str:
|
|
216
|
+
"""一句到期提醒。
|
|
217
|
+
|
|
218
|
+
**不出现"任务/待办/逾期"这类词**。承诺是人和人之间的事,
|
|
219
|
+
不是工单系统。所以句型跟他当初说的那句话尽量贴近:
|
|
220
|
+
「你说月底还我两袋」,而不是「您有一条逾期记录」。
|
|
221
|
+
"""
|
|
222
|
+
p = persona or Persona()
|
|
223
|
+
creditor, item_name = _split_key(item.get("state_key", ""))
|
|
224
|
+
who = p.name_of(creditor, "你")
|
|
225
|
+
thing = p.name_of(item_name, "")
|
|
226
|
+
|
|
227
|
+
qty = item.get("remaining")
|
|
228
|
+
if qty in (None, 0):
|
|
229
|
+
qty = item.get("quantity")
|
|
230
|
+
qty_text = f"{qty:g}" if isinstance(qty, (int, float)) else str(qty or "")
|
|
231
|
+
unit = item.get("unit") or ""
|
|
232
|
+
body = f"{qty_text}{unit}{thing}".strip()
|
|
233
|
+
|
|
234
|
+
when = describe_due(item.get("due_at"))
|
|
235
|
+
if item.get("due_at"):
|
|
236
|
+
return f"{p.address},你说{when}要还{who} {body},今天到期了。"
|
|
237
|
+
return f"{p.address},你答应过还{who} {body},还没还呢。"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ============================================================ 查账的答案
|
|
241
|
+
def _conflict(reason: str | None) -> str:
|
|
242
|
+
"""把内核的冲突原因翻成人话。
|
|
243
|
+
|
|
244
|
+
只有真正见过的几种才翻。翻了不认识的,就把原文念出来——
|
|
245
|
+
**宁可说得笨,不可说得像懂了。**
|
|
246
|
+
"""
|
|
247
|
+
if not reason:
|
|
248
|
+
return "有个地方对不上"
|
|
249
|
+
if reason.startswith("unit_mismatch:"):
|
|
250
|
+
_, _, pair = reason.partition(":")
|
|
251
|
+
left, _, right = pair.partition("!=")
|
|
252
|
+
return f"前面记的是「{left}」,这次说的是「{right}」,两个单位我不敢直接加减"
|
|
253
|
+
if reason.startswith("non_negative_violated"):
|
|
254
|
+
return "这么算会变成负数"
|
|
255
|
+
return reason
|
|
256
|
+
|
|
257
|
+
def answer(detail: dict[str, Any], persona: Persona | None = None) -> str:
|
|
258
|
+
"""「还剩几捆?」/「我一共欠多少?」——把账上的数念出来。
|
|
259
|
+
|
|
260
|
+
三种答案**必须是三句不同的话**,不能合并成一句"没有":
|
|
261
|
+
|
|
262
|
+
known 3 捆
|
|
263
|
+
unknown 记过,但依据被撤了 —— 不是 0,是"现在说不准"
|
|
264
|
+
not_found 从来没记过 —— 不是"你没欠",是"你没跟我说过"
|
|
265
|
+
|
|
266
|
+
把后两种都说成「没有」,就是把"系统的问题"和"事实的问题"
|
|
267
|
+
讲成同一件事。而这套系统存在的全部理由,就是分得清这两者。
|
|
268
|
+
"""
|
|
269
|
+
p = persona or Persona()
|
|
270
|
+
status = detail.get("status")
|
|
271
|
+
creditor, item_from_key = _split_key(detail.get("state_key") or "")
|
|
272
|
+
|
|
273
|
+
if status == "not_found":
|
|
274
|
+
return f"{p.address},这件事你没跟我说过,我这儿没记着。"
|
|
275
|
+
|
|
276
|
+
if status == "disputed":
|
|
277
|
+
return f"{p.address},这笔账我这儿对不上({_conflict(detail.get('reason'))}),不敢报数。"
|
|
278
|
+
|
|
279
|
+
if status == "unknown":
|
|
280
|
+
return (
|
|
281
|
+
f"{p.address},这笔我记过,"
|
|
282
|
+
f"但当时那条依据后来被撤了,现在说不准。"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
value = detail.get("value")
|
|
286
|
+
unit = p.name_of(detail.get("unit"), "")
|
|
287
|
+
item = p.name_of(detail.get("item") or item_from_key, "")
|
|
288
|
+
who = p.name_of(detail.get("creditor") or creditor, "对方")
|
|
289
|
+
value_t = f"{value:g}" if isinstance(value, (int, float)) else str(value)
|
|
290
|
+
|
|
291
|
+
body = f"{value_t} {unit}{item}".strip()
|
|
292
|
+
return f"{p.address},还欠{who} {body}。"
|
|
@@ -212,6 +212,19 @@ class MemoryService:
|
|
|
212
212
|
return {"ok": True, "session": session,
|
|
213
213
|
"points": Memory(self.engine).curve(scope, trace_id, points)}
|
|
214
214
|
|
|
215
|
+
def memory_forget(self, session: str, trace_id: str, *,
|
|
216
|
+
user: str = "user") -> dict[str, Any]:
|
|
217
|
+
"""删一条记忆。主 AI 主动删时用;用户说"忘掉这条"也走这里。"""
|
|
218
|
+
scope = self.scope_of(session, user)
|
|
219
|
+
return {"ok": True, "session": session,
|
|
220
|
+
**Memory(self.engine).forget(scope, trace_id)}
|
|
221
|
+
|
|
222
|
+
def memory_stats(self, session: str, *, user: str = "user") -> dict[str, Any]:
|
|
223
|
+
"""库里有多少条记忆、多少条被删过、多少条锚住——透明化用。"""
|
|
224
|
+
scope = self.scope_of(session, user)
|
|
225
|
+
return {"ok": True, "session": session,
|
|
226
|
+
**Memory(self.engine).stats(scope)}
|
|
227
|
+
|
|
215
228
|
def close(self) -> None:
|
|
216
229
|
self.engine.close()
|
|
217
230
|
|
|
@@ -366,6 +379,17 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
366
379
|
user=user,
|
|
367
380
|
limit=int(_num_field(body, "limit", 5, 1, 50)),
|
|
368
381
|
)
|
|
382
|
+
elif route == "/memory/stats":
|
|
383
|
+
payload = self.service.memory_stats(session, user=user)
|
|
384
|
+
elif route == "/memory/forget":
|
|
385
|
+
trace_id = _str_field(body, "trace_id")
|
|
386
|
+
if not trace_id:
|
|
387
|
+
raise BadRequest("missing_trace_id")
|
|
388
|
+
try:
|
|
389
|
+
payload = self.service.memory_forget(session, trace_id, user=user)
|
|
390
|
+
except ValueError as exc:
|
|
391
|
+
# 库里没有这条 / 不属于这个 scope:调用方参数问题,不是服务端崩了。
|
|
392
|
+
raise BadRequest(str(exc)) from exc
|
|
369
393
|
elif route in ("/memory/mark", "/memory/unmark", "/memory/curve"):
|
|
370
394
|
trace_id = _str_field(body, "trace_id")
|
|
371
395
|
if not trace_id:
|