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,573 +1,573 @@
|
|
|
1
|
-
"""情绪与关系:短期会消,长期不会自己好。
|
|
2
|
-
|
|
3
|
-
方案第六章的全部难点,可以压成一句:
|
|
4
|
-
|
|
5
|
-
消气不等于原谅,原谅不等于遗忘。
|
|
6
|
-
|
|
7
|
-
所以这里是**两条独立的线**,用两套规则:
|
|
8
|
-
|
|
9
|
-
情绪 emotion_events 有衰减。半天不生气就不生气了。
|
|
10
|
-
关系 relationship_events 没有衰减。信任掉下去,不会因为过了三十天自己长回来。
|
|
11
|
-
|
|
12
|
-
把它们合成一条曲线,就会出现「三个月后 AI 自己原谅了」——
|
|
13
|
-
那不是仿人,那是没有记性。
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## 情绪为什么也可以"只记事件"
|
|
18
|
-
|
|
19
|
-
和权重曲线同一个理由:**存下来的值总有一天会和事实对不上。**
|
|
20
|
-
|
|
21
|
-
情绪是"被什么推了多少次"的结果。存一个 `anger = 37` 的数字,
|
|
22
|
-
就再也答不了"这三十七是怎么来的"。而这句话恰恰是这个模块存在的意义。
|
|
23
|
-
|
|
24
|
-
所以只记 `emotion_events`(谁在什么时候把哪种情绪推了多少、为什么),
|
|
25
|
-
当前强度由 `intensity()` 重放算出来。
|
|
26
|
-
|
|
27
|
-
## 为什么要有饱和
|
|
28
|
-
|
|
29
|
-
6.5:避免无条件叠加负面情绪,导致永久敌对或关系无法修复。
|
|
30
|
-
|
|
31
|
-
纯累加的话,被同一件事激怒二十次就等于 20 倍愤怒,
|
|
32
|
-
而人不是这样的——第一次最疼,第十次已经麻了。
|
|
33
|
-
所以重放时每一步都夹在 `[0, cap]` 里,越往上越难加。
|
|
34
|
-
|
|
35
|
-
## 为什么"未了结的事"要单独一张表
|
|
36
|
-
|
|
37
|
-
因为它是**唯一不随时间消失的东西**。
|
|
38
|
-
|
|
39
|
-
情绪可以自己退到 0(消气),但只要这条矛盾还开着,
|
|
40
|
-
关系就不能自动回暖(没原谅)。方案里那句「不生气,但还等着一个解释」,
|
|
41
|
-
就是这张表存在的全部理由。
|
|
42
|
-
|
|
43
|
-
必测场景 F(高冷角色:未修复关系前不自动恢复亲密)也落在这儿:
|
|
44
|
-
`grievances` 有 open 的时候,`closeness` 不许回到 baseline 以上。
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
from __future__ import annotations
|
|
48
|
-
|
|
49
|
-
import math
|
|
50
|
-
from datetime import datetime, timezone
|
|
51
|
-
|
|
52
|
-
from .engine import digest
|
|
53
|
-
|
|
54
|
-
# 情绪种类。名字用英文,因为它们是键;中文只出现在渲染里。
|
|
55
|
-
EMOTIONS = ("anger", "joy", "sadness", "anxiety", "grievance")
|
|
56
|
-
|
|
57
|
-
# 各情绪的**半衰期(天)**。用半衰期而不是衰减系数,
|
|
58
|
-
# 因为"多久消一半"是人能直接判断的数,λ 不是。
|
|
59
|
-
#
|
|
60
|
-
# anger 半天 —— 气来得快去得也快
|
|
61
|
-
# joy 一天
|
|
62
|
-
# sadness 三天 —— 难过比生气黏人
|
|
63
|
-
# anxiety 一天
|
|
64
|
-
# grievance 两天 —— 委屈散得慢,但不至于留一辈子
|
|
65
|
-
_HALF_LIFE_DAYS = {
|
|
66
|
-
"anger": 0.5,
|
|
67
|
-
"joy": 1.0,
|
|
68
|
-
"sadness": 3.0,
|
|
69
|
-
"anxiety": 1.0,
|
|
70
|
-
"grievance": 2.0,
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
# 情绪强度上限。**这个数就是 6.5 说的"不无限累积"。**
|
|
74
|
-
EMOTION_CAP = 100.0
|
|
75
|
-
|
|
76
|
-
# 关系维度。这三个是方案 6.2 列的长期项:信任、亲密、隔阂感、尊重。
|
|
77
|
-
# 「隔阂」不单列一维,它是 closeness 的反面——同一根轴的两端,
|
|
78
|
-
# 拆成两个字段就会出现"又亲密又隔阂"这种自相矛盾的状态。
|
|
79
|
-
RELATION_DIMENSIONS = ("trust", "closeness", "respect")
|
|
80
|
-
|
|
81
|
-
# 初见时的关系基线。没有这组数,第一次见面的人 trust 会是 0,
|
|
82
|
-
# 而 0 意味着"我本来就不信你"——那不是初见,那是结过仇。
|
|
83
|
-
_BASELINE = {
|
|
84
|
-
"trust": 50.0,
|
|
85
|
-
"closeness": 30.0,
|
|
86
|
-
"respect": 50.0,
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
RELATION_CAP = 100.0
|
|
90
|
-
|
|
91
|
-
# 未了结的事的状态
|
|
92
|
-
GRIEVANCE_OPEN = "open"
|
|
93
|
-
GRIEVANCE_REPAIRED = "repaired" # 事情说清楚了
|
|
94
|
-
GRIEVANCE_FORGIVEN = "forgiven" # 人原谅了,但事发生过
|
|
95
|
-
|
|
96
|
-
_NAMES = {
|
|
97
|
-
"anger": "生气",
|
|
98
|
-
"joy": "高兴",
|
|
99
|
-
"sadness": "难过",
|
|
100
|
-
"anxiety": "不安",
|
|
101
|
-
"grievance": "委屈",
|
|
102
|
-
"trust": "信任",
|
|
103
|
-
"closeness": "亲近",
|
|
104
|
-
"respect": "尊重",
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _parse(ts: str | None) -> datetime | None:
|
|
109
|
-
if not ts:
|
|
110
|
-
return None
|
|
111
|
-
try:
|
|
112
|
-
dt = datetime.fromisoformat(ts)
|
|
113
|
-
except ValueError:
|
|
114
|
-
return None
|
|
115
|
-
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def elapsed_days(since: str | None, now: datetime) -> float:
|
|
119
|
-
then = _parse(since)
|
|
120
|
-
if then is None:
|
|
121
|
-
return 0.0
|
|
122
|
-
return max(0.0, (now - then).total_seconds() / 86400.0)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
class Emotion:
|
|
126
|
-
"""短期情绪。会自己消退。"""
|
|
127
|
-
|
|
128
|
-
def __init__(self, engine):
|
|
129
|
-
self.engine = engine
|
|
130
|
-
self._seq = 0
|
|
131
|
-
|
|
132
|
-
# ---------------------------------------------------------- 写
|
|
133
|
-
def feel(self, profile: dict, emotion: str, delta: float, *,
|
|
134
|
-
reason: str, evidence_trace_id: str | None = None) -> dict:
|
|
135
|
-
"""被一件事推了一下。
|
|
136
|
-
|
|
137
|
-
`delta` 正数是加剧,负数是安抚。**必须给 reason**——
|
|
138
|
-
没有原因的情绪,事后答不了"你当时为什么生气",
|
|
139
|
-
而那个问题正是这个模块要能回答的。
|
|
140
|
-
"""
|
|
141
|
-
if emotion not in EMOTIONS:
|
|
142
|
-
raise ValueError(f"未知情绪: {emotion}")
|
|
143
|
-
if not reason:
|
|
144
|
-
raise ValueError("情绪事件必须说明原因")
|
|
145
|
-
|
|
146
|
-
self._seq += 1
|
|
147
|
-
event_id = "em_" + digest(
|
|
148
|
-
"emotion", profile["profile_id"], emotion, reason,
|
|
149
|
-
self.engine.now(), str(self._seq),
|
|
150
|
-
)[:20]
|
|
151
|
-
self.engine._conn().execute(
|
|
152
|
-
"INSERT INTO emotion_events(event_id, scope_id, profile_id, emotion,"
|
|
153
|
-
" delta, reason, evidence_trace_id, occurred_at)"
|
|
154
|
-
" VALUES (?,?,?,?,?,?,?,?)",
|
|
155
|
-
(event_id, profile["scope_id"], profile["profile_id"], emotion,
|
|
156
|
-
float(delta), reason, evidence_trace_id, self.engine.now()),
|
|
157
|
-
)
|
|
158
|
-
return {
|
|
159
|
-
"event_id": event_id,
|
|
160
|
-
"emotion": emotion,
|
|
161
|
-
"intensity": self.intensity(profile, emotion),
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
# ---------------------------------------------------------- 读
|
|
165
|
-
def intensity(self, profile: dict, emotion: str,
|
|
166
|
-
now: datetime | None = None,
|
|
167
|
-
half_life_days: float | None = None) -> float:
|
|
168
|
-
"""**重放**出当前强度。
|
|
169
|
-
|
|
170
|
-
逐步重放而不是求和,是为了让饱和生效:
|
|
171
|
-
|
|
172
|
-
每到一个事件:先按流逝的时间衰减,再加上这次的影响,夹到上限里
|
|
173
|
-
|
|
174
|
-
过一段时间再衰减一次到此刻。
|
|
175
|
-
|
|
176
|
-
`half_life_days` 让角色卡覆盖默认衰减速度(7.2「衰减速度」)。
|
|
177
|
-
它只改**气消得多快**,不改"这件事发生过"。
|
|
178
|
-
"""
|
|
179
|
-
now = now or _parse(self.engine.now()) or datetime.now(timezone.utc)
|
|
180
|
-
rate = _rate(emotion, half_life_days)
|
|
181
|
-
|
|
182
|
-
rows = self.engine._conn().execute(
|
|
183
|
-
"SELECT delta, occurred_at FROM emotion_events"
|
|
184
|
-
" WHERE profile_id=? AND emotion=? ORDER BY occurred_at, rowid",
|
|
185
|
-
(profile["profile_id"], emotion),
|
|
186
|
-
).fetchall()
|
|
187
|
-
if not rows:
|
|
188
|
-
return 0.0
|
|
189
|
-
|
|
190
|
-
value = 0.0
|
|
191
|
-
prev = _parse(rows[0]["occurred_at"]) or now
|
|
192
|
-
for i, row in enumerate(rows):
|
|
193
|
-
at = _parse(row["occurred_at"]) or prev
|
|
194
|
-
if i > 0:
|
|
195
|
-
value *= math.exp(-rate * max(0.0, (at - prev).total_seconds() / 86400.0))
|
|
196
|
-
value = _clamp(value + float(row["delta"]), 0.0, EMOTION_CAP)
|
|
197
|
-
prev = at
|
|
198
|
-
|
|
199
|
-
value *= math.exp(-rate * max(0.0, (now - prev).total_seconds() / 86400.0))
|
|
200
|
-
return round(value, 3)
|
|
201
|
-
|
|
202
|
-
def mood(self, profile: dict, now: datetime | None = None) -> dict[str, float]:
|
|
203
|
-
"""当前所有情绪的强度。0 的不返回——空着的情绪不是信息。"""
|
|
204
|
-
got = {e: self.intensity(profile, e, now=now) for e in EMOTIONS}
|
|
205
|
-
return {e: v for e, v in got.items() if v > 0}
|
|
206
|
-
|
|
207
|
-
def dominant(self, profile: dict) -> tuple[str | None, float]:
|
|
208
|
-
"""现在最强烈的那个情绪。没有就是 None。"""
|
|
209
|
-
mood = self.mood(profile)
|
|
210
|
-
if not mood:
|
|
211
|
-
return None, 0.0
|
|
212
|
-
name = max(mood, key=lambda k: mood[k])
|
|
213
|
-
return name, mood[name]
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
class Relationship:
|
|
217
|
-
"""长期关系。**不随时间变化**——只有事件能改它。"""
|
|
218
|
-
|
|
219
|
-
def __init__(self, engine):
|
|
220
|
-
self.engine = engine
|
|
221
|
-
self._seq = 0
|
|
222
|
-
|
|
223
|
-
# ---------------------------------------------------------- 写
|
|
224
|
-
def shift(self, profile: dict, dimension: str, delta: float, *,
|
|
225
|
-
reason: str, is_repair: bool = False,
|
|
226
|
-
evidence_trace_id: str | None = None) -> dict:
|
|
227
|
-
"""关系动了一下。
|
|
228
|
-
|
|
229
|
-
**为什么不给这个方法加一个"时间衰减"参数**:
|
|
230
|
-
因为那样这个模块就失去了它存在的理由。
|
|
231
|
-
信任掉下去只能靠两件事回来——对方做了新的可信的事,或者把话说开了。
|
|
232
|
-
时间不是其中之一。
|
|
233
|
-
"""
|
|
234
|
-
if dimension not in RELATION_DIMENSIONS:
|
|
235
|
-
raise ValueError(f"未知关系维度: {dimension}")
|
|
236
|
-
if not reason:
|
|
237
|
-
raise ValueError("关系事件必须说明原因")
|
|
238
|
-
|
|
239
|
-
self._seq += 1
|
|
240
|
-
event_id = "re_" + digest(
|
|
241
|
-
"relation", profile["profile_id"], dimension, reason,
|
|
242
|
-
self.engine.now(), str(self._seq),
|
|
243
|
-
)[:20]
|
|
244
|
-
self.engine._conn().execute(
|
|
245
|
-
"INSERT INTO relationship_events(event_id, scope_id, profile_id,"
|
|
246
|
-
" dimension, delta, reason, evidence_trace_id, is_repair, occurred_at)"
|
|
247
|
-
" VALUES (?,?,?,?,?,?,?,?,?)",
|
|
248
|
-
(event_id, profile["scope_id"], profile["profile_id"], dimension,
|
|
249
|
-
float(delta), reason, evidence_trace_id, 1 if is_repair else 0,
|
|
250
|
-
self.engine.now()),
|
|
251
|
-
)
|
|
252
|
-
return {
|
|
253
|
-
"event_id": event_id,
|
|
254
|
-
"dimension": dimension,
|
|
255
|
-
"value": self.value(profile, dimension),
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
# ---------------------------------------------------------- 读
|
|
259
|
-
def value(self, profile: dict, dimension: str) -> float:
|
|
260
|
-
"""当前值 = 基线 + 全部事件。**没有时间项。**"""
|
|
261
|
-
if dimension not in RELATION_DIMENSIONS:
|
|
262
|
-
raise ValueError(f"未知关系维度: {dimension}")
|
|
263
|
-
row = self.engine._conn().execute(
|
|
264
|
-
"SELECT COALESCE(SUM(delta), 0) s FROM relationship_events"
|
|
265
|
-
" WHERE profile_id=? AND dimension=?",
|
|
266
|
-
(profile["profile_id"], dimension),
|
|
267
|
-
).fetchone()
|
|
268
|
-
return round(_clamp(_BASELINE[dimension] + float(row["s"]),
|
|
269
|
-
0.0, RELATION_CAP), 3)
|
|
270
|
-
|
|
271
|
-
def state(self, profile: dict) -> dict[str, float]:
|
|
272
|
-
return {d: self.value(profile, d) for d in RELATION_DIMENSIONS}
|
|
273
|
-
|
|
274
|
-
def repairs(self, profile: dict) -> int:
|
|
275
|
-
"""修复过几次。给"这段关系是不是被认真处理过"一个凭据。"""
|
|
276
|
-
row = self.engine._conn().execute(
|
|
277
|
-
"SELECT COUNT(*) c FROM relationship_events"
|
|
278
|
-
" WHERE profile_id=? AND is_repair=1",
|
|
279
|
-
(profile["profile_id"],),
|
|
280
|
-
).fetchone()
|
|
281
|
-
return int(row["c"])
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
class Grievances:
|
|
285
|
-
"""未了结的事。**唯一不随时间消失的东西。**
|
|
286
|
-
|
|
287
|
-
情绪会退,这条记录不会。它一直开着,直到有人把话说开。
|
|
288
|
-
"""
|
|
289
|
-
|
|
290
|
-
def __init__(self, engine):
|
|
291
|
-
self.engine = engine
|
|
292
|
-
self._seq = 0
|
|
293
|
-
|
|
294
|
-
def open(self, profile: dict, summary: str, *,
|
|
295
|
-
severity: float = 1.0,
|
|
296
|
-
evidence_trace_id: str | None = None) -> dict:
|
|
297
|
-
if not summary:
|
|
298
|
-
raise ValueError("未了结的事必须有概述")
|
|
299
|
-
self._seq += 1
|
|
300
|
-
grievance_id = "gr_" + digest(
|
|
301
|
-
"grievance", profile["profile_id"], summary,
|
|
302
|
-
self.engine.now(), str(self._seq),
|
|
303
|
-
)[:20]
|
|
304
|
-
self.engine._conn().execute(
|
|
305
|
-
"INSERT INTO grievances(grievance_id, scope_id, profile_id, summary,"
|
|
306
|
-
" severity, status, evidence_trace_id, opened_at)"
|
|
307
|
-
" VALUES (?,?,?,?,?,?,?,?)",
|
|
308
|
-
(grievance_id, profile["scope_id"], profile["profile_id"],
|
|
309
|
-
summary, float(severity), GRIEVANCE_OPEN, evidence_trace_id,
|
|
310
|
-
self.engine.now()),
|
|
311
|
-
)
|
|
312
|
-
return self._by_id(grievance_id) or {}
|
|
313
|
-
|
|
314
|
-
def close(self, grievance_id: str, *,
|
|
315
|
-
forgiven: bool = False) -> dict | None:
|
|
316
|
-
"""把一件事了结掉。
|
|
317
|
-
|
|
318
|
-
`repaired` 和 `forgiven` 都关闭,但**不是一回事**:
|
|
319
|
-
|
|
320
|
-
repaired 事情说清楚了(误会解除、对方解释了)
|
|
321
|
-
forgiven 我原谅了(事发生过,我记得,但不再追究)
|
|
322
|
-
|
|
323
|
-
两个都留着,因为「他原谅过我一件事」和「那件事讲清楚了」
|
|
324
|
-
在以后翻旧账时是两种完全不同的证据。
|
|
325
|
-
"""
|
|
326
|
-
status = GRIEVANCE_FORGIVEN if forgiven else GRIEVANCE_REPAIRED
|
|
327
|
-
self.engine._conn().execute(
|
|
328
|
-
"UPDATE grievances SET status=?, closed_at=? WHERE grievance_id=?",
|
|
329
|
-
(status, self.engine.now(), grievance_id),
|
|
330
|
-
)
|
|
331
|
-
return self._by_id(grievance_id)
|
|
332
|
-
|
|
333
|
-
def open_ones(self, profile: dict) -> list[dict]:
|
|
334
|
-
rows = self.engine._conn().execute(
|
|
335
|
-
"SELECT * FROM grievances WHERE profile_id=? AND status=?"
|
|
336
|
-
" ORDER BY severity DESC, opened_at",
|
|
337
|
-
(profile["profile_id"], GRIEVANCE_OPEN),
|
|
338
|
-
).fetchall()
|
|
339
|
-
return [dict(r) for r in rows]
|
|
340
|
-
|
|
341
|
-
def all_of(self, profile: dict) -> list[dict]:
|
|
342
|
-
rows = self.engine._conn().execute(
|
|
343
|
-
"SELECT * FROM grievances WHERE profile_id=? ORDER BY opened_at",
|
|
344
|
-
(profile["profile_id"],),
|
|
345
|
-
).fetchall()
|
|
346
|
-
return [dict(r) for r in rows]
|
|
347
|
-
|
|
348
|
-
def _by_id(self, grievance_id: str) -> dict | None:
|
|
349
|
-
row = self.engine._conn().execute(
|
|
350
|
-
"SELECT * FROM grievances WHERE grievance_id=?", (grievance_id,)
|
|
351
|
-
).fetchone()
|
|
352
|
-
return dict(row) if row else None
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
class Mind:
|
|
356
|
-
"""三样东西放在一起,因为**它们总是一起被问**。
|
|
357
|
-
|
|
358
|
-
「我现在什么心情、跟他什么关系、还有什么事没说开」——
|
|
359
|
-
这是主 AI 决定怎么开口之前要看的全部。
|
|
360
|
-
|
|
361
|
-
`character` 是可选的角色卡(阶段五)。它只调节**幅度与倾向**:
|
|
362
|
-
|
|
363
|
-
同样被冒犯,直率角色气得多、温和角色气得少(6.3)
|
|
364
|
-
气消得多快,由角色的半衰期决定(7.2)
|
|
365
|
-
气消了之后怎么表现,由 `stance()` 给(7.4)
|
|
366
|
-
|
|
367
|
-
它**不能**让一件没发生的事发生,也不能让发生过的事消失。
|
|
368
|
-
温柔角色的气只有 10,但那件事照样开着,照样要有人把话说开。
|
|
369
|
-
"""
|
|
370
|
-
|
|
371
|
-
def __init__(self, engine, character=None):
|
|
372
|
-
self.engine = engine
|
|
373
|
-
self.character = character
|
|
374
|
-
self.emotion = Emotion(engine)
|
|
375
|
-
self.relationship = Relationship(engine)
|
|
376
|
-
self.grievances = Grievances(engine)
|
|
377
|
-
|
|
378
|
-
# ---------------------------------------------------------- 角色调节
|
|
379
|
-
def _half_life(self, emotion: str) -> float | None:
|
|
380
|
-
"""角色卡给的半衰期。没给就用默认。"""
|
|
381
|
-
c = self.character
|
|
382
|
-
if c is None:
|
|
383
|
-
return None
|
|
384
|
-
if emotion == "anger":
|
|
385
|
-
return c.anger_half_life_days
|
|
386
|
-
if emotion == "sadness":
|
|
387
|
-
return c.sadness_half_life_days
|
|
388
|
-
return None
|
|
389
|
-
|
|
390
|
-
def mood(self, profile: dict, now: datetime | None = None) -> dict[str, float]:
|
|
391
|
-
"""当前情绪,按**这个角色**的衰减速度算。"""
|
|
392
|
-
got = {
|
|
393
|
-
e: self.emotion.intensity(profile, e, now=now,
|
|
394
|
-
half_life_days=self._half_life(e))
|
|
395
|
-
for e in EMOTIONS
|
|
396
|
-
}
|
|
397
|
-
return {e: v for e, v in got.items() if v > 0}
|
|
398
|
-
|
|
399
|
-
def anger(self, profile: dict) -> float:
|
|
400
|
-
return self.emotion.intensity(profile, "anger",
|
|
401
|
-
half_life_days=self._half_life("anger"))
|
|
402
|
-
|
|
403
|
-
def snapshot(self, profile: dict) -> dict:
|
|
404
|
-
return {
|
|
405
|
-
"mood": self.mood(profile),
|
|
406
|
-
"relationship": self.relationship.state(profile),
|
|
407
|
-
"open_grievances": self.grievances.open_ones(profile),
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
def stance(self, profile: dict):
|
|
411
|
-
"""此刻大概会怎么做。**不是台词**——台词由主 AI 来写。
|
|
412
|
-
|
|
413
|
-
没有角色卡时返回一个中性倾向(不主动、不记嘴、语气平),
|
|
414
|
-
而不是报错:没有角色设定也该能算出一个立场。
|
|
415
|
-
"""
|
|
416
|
-
from .character import CharacterSpec, stance as _stance
|
|
417
|
-
|
|
418
|
-
c = self.character or CharacterSpec(name="(未设定)")
|
|
419
|
-
rel = self.relationship.state(profile)
|
|
420
|
-
return _stance(
|
|
421
|
-
c,
|
|
422
|
-
anger=self.anger(profile),
|
|
423
|
-
trust=rel["trust"],
|
|
424
|
-
closeness=rel["closeness"],
|
|
425
|
-
open_grievances=len(self.grievances.open_ones(profile)),
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
def record_count(self, profile: dict) -> int:
|
|
429
|
-
"""这个人的情绪与关系事件一共有多少条。
|
|
430
|
-
|
|
431
|
-
给"事实抹不掉"一个可见的数字:性格可以让人气消得更快,
|
|
432
|
-
但那些事件一直在库里。
|
|
433
|
-
"""
|
|
434
|
-
row = self.engine._conn().execute(
|
|
435
|
-
"SELECT (SELECT COUNT(*) FROM emotion_events WHERE profile_id=?)"
|
|
436
|
-
" + (SELECT COUNT(*) FROM relationship_events WHERE profile_id=?) c",
|
|
437
|
-
(profile["profile_id"], profile["profile_id"]),
|
|
438
|
-
).fetchone()
|
|
439
|
-
return int(row["c"])
|
|
440
|
-
|
|
441
|
-
def render(self, profile: dict) -> str:
|
|
442
|
-
"""人话版。给主 AI 塞进提示词。
|
|
443
|
-
|
|
444
|
-
刻意把三件事分开写,因为它们**不能互相换算**:
|
|
445
|
-
气消了不代表关系回来了,关系回来了不代表那件事就没了。
|
|
446
|
-
|
|
447
|
-
最后一行是角色倾向。**它不写台词**——只写"会不会先开口、
|
|
448
|
-
嘴上放不放过"。台词留给主 AI,因为同一份倾向可以有很多种说法,
|
|
449
|
-
而写死一句「哼」就是模板了(7.3)。
|
|
450
|
-
"""
|
|
451
|
-
snap = self.snapshot(profile)
|
|
452
|
-
lines = []
|
|
453
|
-
|
|
454
|
-
if snap["mood"]:
|
|
455
|
-
parts = [f"{_NAMES.get(k, k)} {v:.0f}" for k, v in
|
|
456
|
-
sorted(snap["mood"].items(), key=lambda kv: -kv[1])]
|
|
457
|
-
lines.append("现在的心情:" + "、".join(parts))
|
|
458
|
-
else:
|
|
459
|
-
lines.append("现在的心情:平静")
|
|
460
|
-
|
|
461
|
-
rel = snap["relationship"]
|
|
462
|
-
lines.append("跟他的关系:" + "、".join(
|
|
463
|
-
f"{_NAMES.get(k, k)} {v:.0f}" for k, v in rel.items()))
|
|
464
|
-
|
|
465
|
-
if snap["open_grievances"]:
|
|
466
|
-
lines.append("还没说开的事:")
|
|
467
|
-
for g in snap["open_grievances"]:
|
|
468
|
-
lines.append(f" - {g['summary']}")
|
|
469
|
-
|
|
470
|
-
if self.character is not None:
|
|
471
|
-
st = self.stance(profile)
|
|
472
|
-
lines.append("此刻大概会:" + _describe_stance(st))
|
|
473
|
-
return "\n".join(lines)
|
|
474
|
-
|
|
475
|
-
# ---------------------------------------------------------- 复合动作
|
|
476
|
-
def offended(self, profile: dict, *, summary: str, severity: float,
|
|
477
|
-
anger: float, trust_delta: float,
|
|
478
|
-
reason: str,
|
|
479
|
-
evidence_trace_id: str | None = None) -> dict:
|
|
480
|
-
"""被冒犯了:气上来、信任掉、留一条未了结的事。
|
|
481
|
-
|
|
482
|
-
这三件事**必须一起做**,所以合成一个动作。
|
|
483
|
-
少做任何一件都会出现奇怪的状态:
|
|
484
|
-
|
|
485
|
-
只加情绪 → 三十天后气消了,关系自动恢复(假的)
|
|
486
|
-
只掉信任 → 他不知道为什么,也看不到有件事没解决
|
|
487
|
-
只记未了结的事 → 情绪上毫无反应,像一台录音机
|
|
488
|
-
|
|
489
|
-
给了角色卡时,`anger` 和 `trust_delta` 会被**乘**上角色的敏感度
|
|
490
|
-
(6.3:直率角色 +30,温和角色 +10)。这是角色卡唯一能碰的地方——
|
|
491
|
-
它调幅度,不调有没有这件事。
|
|
492
|
-
"""
|
|
493
|
-
c = self.character
|
|
494
|
-
anger_amount = anger * (c.anger_sensitivity if c else 1.0)
|
|
495
|
-
trust_amount = trust_delta * (c.trust_damage if c else 1.0)
|
|
496
|
-
|
|
497
|
-
emo = self.emotion.feel(profile, "anger", anger_amount, reason=reason,
|
|
498
|
-
evidence_trace_id=evidence_trace_id)
|
|
499
|
-
self.emotion.feel(profile, "grievance", severity * 10,
|
|
500
|
-
reason=reason, evidence_trace_id=evidence_trace_id)
|
|
501
|
-
rel = self.relationship.shift(profile, "trust", trust_amount,
|
|
502
|
-
reason=reason,
|
|
503
|
-
evidence_trace_id=evidence_trace_id)
|
|
504
|
-
g = self.grievances.open(profile, summary, severity=severity,
|
|
505
|
-
evidence_trace_id=evidence_trace_id)
|
|
506
|
-
return {"emotion": emo, "relationship": rel, "grievance": g}
|
|
507
|
-
|
|
508
|
-
def repair(self, profile: dict, *, grievance_id: str,
|
|
509
|
-
trust: float = 5.0, closeness: float = 5.0,
|
|
510
|
-
reason: str, forgiven: bool = False,
|
|
511
|
-
evidence_trace_id: str | None = None) -> dict:
|
|
512
|
-
"""把话说开了:关掉那件事,关系回暖一点。
|
|
513
|
-
|
|
514
|
-
注意这里是**唯一**能让 `closeness` 回到基线以上的路径。
|
|
515
|
-
时间做不到,别的正向事件也做不到——
|
|
516
|
-
方案必测场景 F 说的就是这个:未修复关系前不自动回复亲密。
|
|
517
|
-
|
|
518
|
-
角色卡在这里调的是**回流速度**(7.2「关系修复的偏好」):
|
|
519
|
-
傲娇只回八成,温柔回一点三倍。同样一句道歉,
|
|
520
|
-
在不同的人那里分量不一样——但**都要有那句话**。
|
|
521
|
-
"""
|
|
522
|
-
c = self.character
|
|
523
|
-
trust_amount = trust * (c.trust_repair if c else 1.0)
|
|
524
|
-
closeness_amount = closeness * (c.trust_repair if c else 1.0)
|
|
525
|
-
|
|
526
|
-
g = self.grievances.close(grievance_id, forgiven=forgiven)
|
|
527
|
-
self.relationship.shift(profile, "trust", trust_amount, reason=reason,
|
|
528
|
-
is_repair=True,
|
|
529
|
-
evidence_trace_id=evidence_trace_id)
|
|
530
|
-
self.relationship.shift(profile, "closeness", closeness_amount,
|
|
531
|
-
reason=reason, is_repair=True,
|
|
532
|
-
evidence_trace_id=evidence_trace_id)
|
|
533
|
-
# 气消一点,但**不等于消气**——那要看时间。
|
|
534
|
-
self.emotion.feel(profile, "anger", -10.0, reason=reason,
|
|
535
|
-
evidence_trace_id=evidence_trace_id)
|
|
536
|
-
self.emotion.feel(profile, "grievance", -20.0, reason=reason,
|
|
537
|
-
evidence_trace_id=evidence_trace_id)
|
|
538
|
-
return {"grievance": g}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def _rate(emotion: str, half_life_days: float | None = None) -> float:
|
|
542
|
-
"""半衰期换算成衰减系数:λ = ln2 / 半衰期。
|
|
543
|
-
|
|
544
|
-
`half_life_days` 由角色卡给(7.2「衰减速度」)。
|
|
545
|
-
给负数或 0 视为无效,退回默认——宁可让角色卡不生效,
|
|
546
|
-
也不要让一个笔误把情绪变成永不消退。
|
|
547
|
-
"""
|
|
548
|
-
half = half_life_days if half_life_days and half_life_days > 0 \
|
|
549
|
-
else _HALF_LIFE_DAYS.get(emotion, 1.0)
|
|
550
|
-
return math.log(2) / half
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def _clamp(value: float, low: float, high: float) -> float:
|
|
554
|
-
return max(low, min(high, value))
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
_TONE_WORDS = {
|
|
558
|
-
"sharp": "话里带刺",
|
|
559
|
-
"cold": "语气平淡、不多说",
|
|
560
|
-
"warm": "语气缓和",
|
|
561
|
-
"normal": "语气正常",
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
def _describe_stance(st) -> str:
|
|
566
|
-
"""把倾向写成一句人话。**只写行为,不写台词。**"""
|
|
567
|
-
parts = [_TONE_WORDS.get(st.tone, st.tone)]
|
|
568
|
-
parts.append("会先开口" if st.speak_first else "不会先开口")
|
|
569
|
-
if st.will_mention_it:
|
|
570
|
-
parts.append("会提那件事")
|
|
571
|
-
if st.needs_apology:
|
|
572
|
-
parts.append("在等一个解释")
|
|
573
|
-
return ",".join(parts)
|
|
1
|
+
"""情绪与关系:短期会消,长期不会自己好。
|
|
2
|
+
|
|
3
|
+
方案第六章的全部难点,可以压成一句:
|
|
4
|
+
|
|
5
|
+
消气不等于原谅,原谅不等于遗忘。
|
|
6
|
+
|
|
7
|
+
所以这里是**两条独立的线**,用两套规则:
|
|
8
|
+
|
|
9
|
+
情绪 emotion_events 有衰减。半天不生气就不生气了。
|
|
10
|
+
关系 relationship_events 没有衰减。信任掉下去,不会因为过了三十天自己长回来。
|
|
11
|
+
|
|
12
|
+
把它们合成一条曲线,就会出现「三个月后 AI 自己原谅了」——
|
|
13
|
+
那不是仿人,那是没有记性。
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 情绪为什么也可以"只记事件"
|
|
18
|
+
|
|
19
|
+
和权重曲线同一个理由:**存下来的值总有一天会和事实对不上。**
|
|
20
|
+
|
|
21
|
+
情绪是"被什么推了多少次"的结果。存一个 `anger = 37` 的数字,
|
|
22
|
+
就再也答不了"这三十七是怎么来的"。而这句话恰恰是这个模块存在的意义。
|
|
23
|
+
|
|
24
|
+
所以只记 `emotion_events`(谁在什么时候把哪种情绪推了多少、为什么),
|
|
25
|
+
当前强度由 `intensity()` 重放算出来。
|
|
26
|
+
|
|
27
|
+
## 为什么要有饱和
|
|
28
|
+
|
|
29
|
+
6.5:避免无条件叠加负面情绪,导致永久敌对或关系无法修复。
|
|
30
|
+
|
|
31
|
+
纯累加的话,被同一件事激怒二十次就等于 20 倍愤怒,
|
|
32
|
+
而人不是这样的——第一次最疼,第十次已经麻了。
|
|
33
|
+
所以重放时每一步都夹在 `[0, cap]` 里,越往上越难加。
|
|
34
|
+
|
|
35
|
+
## 为什么"未了结的事"要单独一张表
|
|
36
|
+
|
|
37
|
+
因为它是**唯一不随时间消失的东西**。
|
|
38
|
+
|
|
39
|
+
情绪可以自己退到 0(消气),但只要这条矛盾还开着,
|
|
40
|
+
关系就不能自动回暖(没原谅)。方案里那句「不生气,但还等着一个解释」,
|
|
41
|
+
就是这张表存在的全部理由。
|
|
42
|
+
|
|
43
|
+
必测场景 F(高冷角色:未修复关系前不自动恢复亲密)也落在这儿:
|
|
44
|
+
`grievances` 有 open 的时候,`closeness` 不许回到 baseline 以上。
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import math
|
|
50
|
+
from datetime import datetime, timezone
|
|
51
|
+
|
|
52
|
+
from .engine import digest
|
|
53
|
+
|
|
54
|
+
# 情绪种类。名字用英文,因为它们是键;中文只出现在渲染里。
|
|
55
|
+
EMOTIONS = ("anger", "joy", "sadness", "anxiety", "grievance")
|
|
56
|
+
|
|
57
|
+
# 各情绪的**半衰期(天)**。用半衰期而不是衰减系数,
|
|
58
|
+
# 因为"多久消一半"是人能直接判断的数,λ 不是。
|
|
59
|
+
#
|
|
60
|
+
# anger 半天 —— 气来得快去得也快
|
|
61
|
+
# joy 一天
|
|
62
|
+
# sadness 三天 —— 难过比生气黏人
|
|
63
|
+
# anxiety 一天
|
|
64
|
+
# grievance 两天 —— 委屈散得慢,但不至于留一辈子
|
|
65
|
+
_HALF_LIFE_DAYS = {
|
|
66
|
+
"anger": 0.5,
|
|
67
|
+
"joy": 1.0,
|
|
68
|
+
"sadness": 3.0,
|
|
69
|
+
"anxiety": 1.0,
|
|
70
|
+
"grievance": 2.0,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# 情绪强度上限。**这个数就是 6.5 说的"不无限累积"。**
|
|
74
|
+
EMOTION_CAP = 100.0
|
|
75
|
+
|
|
76
|
+
# 关系维度。这三个是方案 6.2 列的长期项:信任、亲密、隔阂感、尊重。
|
|
77
|
+
# 「隔阂」不单列一维,它是 closeness 的反面——同一根轴的两端,
|
|
78
|
+
# 拆成两个字段就会出现"又亲密又隔阂"这种自相矛盾的状态。
|
|
79
|
+
RELATION_DIMENSIONS = ("trust", "closeness", "respect")
|
|
80
|
+
|
|
81
|
+
# 初见时的关系基线。没有这组数,第一次见面的人 trust 会是 0,
|
|
82
|
+
# 而 0 意味着"我本来就不信你"——那不是初见,那是结过仇。
|
|
83
|
+
_BASELINE = {
|
|
84
|
+
"trust": 50.0,
|
|
85
|
+
"closeness": 30.0,
|
|
86
|
+
"respect": 50.0,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
RELATION_CAP = 100.0
|
|
90
|
+
|
|
91
|
+
# 未了结的事的状态
|
|
92
|
+
GRIEVANCE_OPEN = "open"
|
|
93
|
+
GRIEVANCE_REPAIRED = "repaired" # 事情说清楚了
|
|
94
|
+
GRIEVANCE_FORGIVEN = "forgiven" # 人原谅了,但事发生过
|
|
95
|
+
|
|
96
|
+
_NAMES = {
|
|
97
|
+
"anger": "生气",
|
|
98
|
+
"joy": "高兴",
|
|
99
|
+
"sadness": "难过",
|
|
100
|
+
"anxiety": "不安",
|
|
101
|
+
"grievance": "委屈",
|
|
102
|
+
"trust": "信任",
|
|
103
|
+
"closeness": "亲近",
|
|
104
|
+
"respect": "尊重",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse(ts: str | None) -> datetime | None:
|
|
109
|
+
if not ts:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
dt = datetime.fromisoformat(ts)
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def elapsed_days(since: str | None, now: datetime) -> float:
|
|
119
|
+
then = _parse(since)
|
|
120
|
+
if then is None:
|
|
121
|
+
return 0.0
|
|
122
|
+
return max(0.0, (now - then).total_seconds() / 86400.0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Emotion:
|
|
126
|
+
"""短期情绪。会自己消退。"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, engine):
|
|
129
|
+
self.engine = engine
|
|
130
|
+
self._seq = 0
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------- 写
|
|
133
|
+
def feel(self, profile: dict, emotion: str, delta: float, *,
|
|
134
|
+
reason: str, evidence_trace_id: str | None = None) -> dict:
|
|
135
|
+
"""被一件事推了一下。
|
|
136
|
+
|
|
137
|
+
`delta` 正数是加剧,负数是安抚。**必须给 reason**——
|
|
138
|
+
没有原因的情绪,事后答不了"你当时为什么生气",
|
|
139
|
+
而那个问题正是这个模块要能回答的。
|
|
140
|
+
"""
|
|
141
|
+
if emotion not in EMOTIONS:
|
|
142
|
+
raise ValueError(f"未知情绪: {emotion}")
|
|
143
|
+
if not reason:
|
|
144
|
+
raise ValueError("情绪事件必须说明原因")
|
|
145
|
+
|
|
146
|
+
self._seq += 1
|
|
147
|
+
event_id = "em_" + digest(
|
|
148
|
+
"emotion", profile["profile_id"], emotion, reason,
|
|
149
|
+
self.engine.now(), str(self._seq),
|
|
150
|
+
)[:20]
|
|
151
|
+
self.engine._conn().execute(
|
|
152
|
+
"INSERT INTO emotion_events(event_id, scope_id, profile_id, emotion,"
|
|
153
|
+
" delta, reason, evidence_trace_id, occurred_at)"
|
|
154
|
+
" VALUES (?,?,?,?,?,?,?,?)",
|
|
155
|
+
(event_id, profile["scope_id"], profile["profile_id"], emotion,
|
|
156
|
+
float(delta), reason, evidence_trace_id, self.engine.now()),
|
|
157
|
+
)
|
|
158
|
+
return {
|
|
159
|
+
"event_id": event_id,
|
|
160
|
+
"emotion": emotion,
|
|
161
|
+
"intensity": self.intensity(profile, emotion),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------- 读
|
|
165
|
+
def intensity(self, profile: dict, emotion: str,
|
|
166
|
+
now: datetime | None = None,
|
|
167
|
+
half_life_days: float | None = None) -> float:
|
|
168
|
+
"""**重放**出当前强度。
|
|
169
|
+
|
|
170
|
+
逐步重放而不是求和,是为了让饱和生效:
|
|
171
|
+
|
|
172
|
+
每到一个事件:先按流逝的时间衰减,再加上这次的影响,夹到上限里
|
|
173
|
+
|
|
174
|
+
过一段时间再衰减一次到此刻。
|
|
175
|
+
|
|
176
|
+
`half_life_days` 让角色卡覆盖默认衰减速度(7.2「衰减速度」)。
|
|
177
|
+
它只改**气消得多快**,不改"这件事发生过"。
|
|
178
|
+
"""
|
|
179
|
+
now = now or _parse(self.engine.now()) or datetime.now(timezone.utc)
|
|
180
|
+
rate = _rate(emotion, half_life_days)
|
|
181
|
+
|
|
182
|
+
rows = self.engine._conn().execute(
|
|
183
|
+
"SELECT delta, occurred_at FROM emotion_events"
|
|
184
|
+
" WHERE profile_id=? AND emotion=? ORDER BY occurred_at, rowid",
|
|
185
|
+
(profile["profile_id"], emotion),
|
|
186
|
+
).fetchall()
|
|
187
|
+
if not rows:
|
|
188
|
+
return 0.0
|
|
189
|
+
|
|
190
|
+
value = 0.0
|
|
191
|
+
prev = _parse(rows[0]["occurred_at"]) or now
|
|
192
|
+
for i, row in enumerate(rows):
|
|
193
|
+
at = _parse(row["occurred_at"]) or prev
|
|
194
|
+
if i > 0:
|
|
195
|
+
value *= math.exp(-rate * max(0.0, (at - prev).total_seconds() / 86400.0))
|
|
196
|
+
value = _clamp(value + float(row["delta"]), 0.0, EMOTION_CAP)
|
|
197
|
+
prev = at
|
|
198
|
+
|
|
199
|
+
value *= math.exp(-rate * max(0.0, (now - prev).total_seconds() / 86400.0))
|
|
200
|
+
return round(value, 3)
|
|
201
|
+
|
|
202
|
+
def mood(self, profile: dict, now: datetime | None = None) -> dict[str, float]:
|
|
203
|
+
"""当前所有情绪的强度。0 的不返回——空着的情绪不是信息。"""
|
|
204
|
+
got = {e: self.intensity(profile, e, now=now) for e in EMOTIONS}
|
|
205
|
+
return {e: v for e, v in got.items() if v > 0}
|
|
206
|
+
|
|
207
|
+
def dominant(self, profile: dict) -> tuple[str | None, float]:
|
|
208
|
+
"""现在最强烈的那个情绪。没有就是 None。"""
|
|
209
|
+
mood = self.mood(profile)
|
|
210
|
+
if not mood:
|
|
211
|
+
return None, 0.0
|
|
212
|
+
name = max(mood, key=lambda k: mood[k])
|
|
213
|
+
return name, mood[name]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Relationship:
|
|
217
|
+
"""长期关系。**不随时间变化**——只有事件能改它。"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, engine):
|
|
220
|
+
self.engine = engine
|
|
221
|
+
self._seq = 0
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------- 写
|
|
224
|
+
def shift(self, profile: dict, dimension: str, delta: float, *,
|
|
225
|
+
reason: str, is_repair: bool = False,
|
|
226
|
+
evidence_trace_id: str | None = None) -> dict:
|
|
227
|
+
"""关系动了一下。
|
|
228
|
+
|
|
229
|
+
**为什么不给这个方法加一个"时间衰减"参数**:
|
|
230
|
+
因为那样这个模块就失去了它存在的理由。
|
|
231
|
+
信任掉下去只能靠两件事回来——对方做了新的可信的事,或者把话说开了。
|
|
232
|
+
时间不是其中之一。
|
|
233
|
+
"""
|
|
234
|
+
if dimension not in RELATION_DIMENSIONS:
|
|
235
|
+
raise ValueError(f"未知关系维度: {dimension}")
|
|
236
|
+
if not reason:
|
|
237
|
+
raise ValueError("关系事件必须说明原因")
|
|
238
|
+
|
|
239
|
+
self._seq += 1
|
|
240
|
+
event_id = "re_" + digest(
|
|
241
|
+
"relation", profile["profile_id"], dimension, reason,
|
|
242
|
+
self.engine.now(), str(self._seq),
|
|
243
|
+
)[:20]
|
|
244
|
+
self.engine._conn().execute(
|
|
245
|
+
"INSERT INTO relationship_events(event_id, scope_id, profile_id,"
|
|
246
|
+
" dimension, delta, reason, evidence_trace_id, is_repair, occurred_at)"
|
|
247
|
+
" VALUES (?,?,?,?,?,?,?,?,?)",
|
|
248
|
+
(event_id, profile["scope_id"], profile["profile_id"], dimension,
|
|
249
|
+
float(delta), reason, evidence_trace_id, 1 if is_repair else 0,
|
|
250
|
+
self.engine.now()),
|
|
251
|
+
)
|
|
252
|
+
return {
|
|
253
|
+
"event_id": event_id,
|
|
254
|
+
"dimension": dimension,
|
|
255
|
+
"value": self.value(profile, dimension),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------- 读
|
|
259
|
+
def value(self, profile: dict, dimension: str) -> float:
|
|
260
|
+
"""当前值 = 基线 + 全部事件。**没有时间项。**"""
|
|
261
|
+
if dimension not in RELATION_DIMENSIONS:
|
|
262
|
+
raise ValueError(f"未知关系维度: {dimension}")
|
|
263
|
+
row = self.engine._conn().execute(
|
|
264
|
+
"SELECT COALESCE(SUM(delta), 0) s FROM relationship_events"
|
|
265
|
+
" WHERE profile_id=? AND dimension=?",
|
|
266
|
+
(profile["profile_id"], dimension),
|
|
267
|
+
).fetchone()
|
|
268
|
+
return round(_clamp(_BASELINE[dimension] + float(row["s"]),
|
|
269
|
+
0.0, RELATION_CAP), 3)
|
|
270
|
+
|
|
271
|
+
def state(self, profile: dict) -> dict[str, float]:
|
|
272
|
+
return {d: self.value(profile, d) for d in RELATION_DIMENSIONS}
|
|
273
|
+
|
|
274
|
+
def repairs(self, profile: dict) -> int:
|
|
275
|
+
"""修复过几次。给"这段关系是不是被认真处理过"一个凭据。"""
|
|
276
|
+
row = self.engine._conn().execute(
|
|
277
|
+
"SELECT COUNT(*) c FROM relationship_events"
|
|
278
|
+
" WHERE profile_id=? AND is_repair=1",
|
|
279
|
+
(profile["profile_id"],),
|
|
280
|
+
).fetchone()
|
|
281
|
+
return int(row["c"])
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class Grievances:
|
|
285
|
+
"""未了结的事。**唯一不随时间消失的东西。**
|
|
286
|
+
|
|
287
|
+
情绪会退,这条记录不会。它一直开着,直到有人把话说开。
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(self, engine):
|
|
291
|
+
self.engine = engine
|
|
292
|
+
self._seq = 0
|
|
293
|
+
|
|
294
|
+
def open(self, profile: dict, summary: str, *,
|
|
295
|
+
severity: float = 1.0,
|
|
296
|
+
evidence_trace_id: str | None = None) -> dict:
|
|
297
|
+
if not summary:
|
|
298
|
+
raise ValueError("未了结的事必须有概述")
|
|
299
|
+
self._seq += 1
|
|
300
|
+
grievance_id = "gr_" + digest(
|
|
301
|
+
"grievance", profile["profile_id"], summary,
|
|
302
|
+
self.engine.now(), str(self._seq),
|
|
303
|
+
)[:20]
|
|
304
|
+
self.engine._conn().execute(
|
|
305
|
+
"INSERT INTO grievances(grievance_id, scope_id, profile_id, summary,"
|
|
306
|
+
" severity, status, evidence_trace_id, opened_at)"
|
|
307
|
+
" VALUES (?,?,?,?,?,?,?,?)",
|
|
308
|
+
(grievance_id, profile["scope_id"], profile["profile_id"],
|
|
309
|
+
summary, float(severity), GRIEVANCE_OPEN, evidence_trace_id,
|
|
310
|
+
self.engine.now()),
|
|
311
|
+
)
|
|
312
|
+
return self._by_id(grievance_id) or {}
|
|
313
|
+
|
|
314
|
+
def close(self, grievance_id: str, *,
|
|
315
|
+
forgiven: bool = False) -> dict | None:
|
|
316
|
+
"""把一件事了结掉。
|
|
317
|
+
|
|
318
|
+
`repaired` 和 `forgiven` 都关闭,但**不是一回事**:
|
|
319
|
+
|
|
320
|
+
repaired 事情说清楚了(误会解除、对方解释了)
|
|
321
|
+
forgiven 我原谅了(事发生过,我记得,但不再追究)
|
|
322
|
+
|
|
323
|
+
两个都留着,因为「他原谅过我一件事」和「那件事讲清楚了」
|
|
324
|
+
在以后翻旧账时是两种完全不同的证据。
|
|
325
|
+
"""
|
|
326
|
+
status = GRIEVANCE_FORGIVEN if forgiven else GRIEVANCE_REPAIRED
|
|
327
|
+
self.engine._conn().execute(
|
|
328
|
+
"UPDATE grievances SET status=?, closed_at=? WHERE grievance_id=?",
|
|
329
|
+
(status, self.engine.now(), grievance_id),
|
|
330
|
+
)
|
|
331
|
+
return self._by_id(grievance_id)
|
|
332
|
+
|
|
333
|
+
def open_ones(self, profile: dict) -> list[dict]:
|
|
334
|
+
rows = self.engine._conn().execute(
|
|
335
|
+
"SELECT * FROM grievances WHERE profile_id=? AND status=?"
|
|
336
|
+
" ORDER BY severity DESC, opened_at",
|
|
337
|
+
(profile["profile_id"], GRIEVANCE_OPEN),
|
|
338
|
+
).fetchall()
|
|
339
|
+
return [dict(r) for r in rows]
|
|
340
|
+
|
|
341
|
+
def all_of(self, profile: dict) -> list[dict]:
|
|
342
|
+
rows = self.engine._conn().execute(
|
|
343
|
+
"SELECT * FROM grievances WHERE profile_id=? ORDER BY opened_at",
|
|
344
|
+
(profile["profile_id"],),
|
|
345
|
+
).fetchall()
|
|
346
|
+
return [dict(r) for r in rows]
|
|
347
|
+
|
|
348
|
+
def _by_id(self, grievance_id: str) -> dict | None:
|
|
349
|
+
row = self.engine._conn().execute(
|
|
350
|
+
"SELECT * FROM grievances WHERE grievance_id=?", (grievance_id,)
|
|
351
|
+
).fetchone()
|
|
352
|
+
return dict(row) if row else None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class Mind:
|
|
356
|
+
"""三样东西放在一起,因为**它们总是一起被问**。
|
|
357
|
+
|
|
358
|
+
「我现在什么心情、跟他什么关系、还有什么事没说开」——
|
|
359
|
+
这是主 AI 决定怎么开口之前要看的全部。
|
|
360
|
+
|
|
361
|
+
`character` 是可选的角色卡(阶段五)。它只调节**幅度与倾向**:
|
|
362
|
+
|
|
363
|
+
同样被冒犯,直率角色气得多、温和角色气得少(6.3)
|
|
364
|
+
气消得多快,由角色的半衰期决定(7.2)
|
|
365
|
+
气消了之后怎么表现,由 `stance()` 给(7.4)
|
|
366
|
+
|
|
367
|
+
它**不能**让一件没发生的事发生,也不能让发生过的事消失。
|
|
368
|
+
温柔角色的气只有 10,但那件事照样开着,照样要有人把话说开。
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def __init__(self, engine, character=None):
|
|
372
|
+
self.engine = engine
|
|
373
|
+
self.character = character
|
|
374
|
+
self.emotion = Emotion(engine)
|
|
375
|
+
self.relationship = Relationship(engine)
|
|
376
|
+
self.grievances = Grievances(engine)
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------- 角色调节
|
|
379
|
+
def _half_life(self, emotion: str) -> float | None:
|
|
380
|
+
"""角色卡给的半衰期。没给就用默认。"""
|
|
381
|
+
c = self.character
|
|
382
|
+
if c is None:
|
|
383
|
+
return None
|
|
384
|
+
if emotion == "anger":
|
|
385
|
+
return c.anger_half_life_days
|
|
386
|
+
if emotion == "sadness":
|
|
387
|
+
return c.sadness_half_life_days
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
def mood(self, profile: dict, now: datetime | None = None) -> dict[str, float]:
|
|
391
|
+
"""当前情绪,按**这个角色**的衰减速度算。"""
|
|
392
|
+
got = {
|
|
393
|
+
e: self.emotion.intensity(profile, e, now=now,
|
|
394
|
+
half_life_days=self._half_life(e))
|
|
395
|
+
for e in EMOTIONS
|
|
396
|
+
}
|
|
397
|
+
return {e: v for e, v in got.items() if v > 0}
|
|
398
|
+
|
|
399
|
+
def anger(self, profile: dict) -> float:
|
|
400
|
+
return self.emotion.intensity(profile, "anger",
|
|
401
|
+
half_life_days=self._half_life("anger"))
|
|
402
|
+
|
|
403
|
+
def snapshot(self, profile: dict) -> dict:
|
|
404
|
+
return {
|
|
405
|
+
"mood": self.mood(profile),
|
|
406
|
+
"relationship": self.relationship.state(profile),
|
|
407
|
+
"open_grievances": self.grievances.open_ones(profile),
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
def stance(self, profile: dict):
|
|
411
|
+
"""此刻大概会怎么做。**不是台词**——台词由主 AI 来写。
|
|
412
|
+
|
|
413
|
+
没有角色卡时返回一个中性倾向(不主动、不记嘴、语气平),
|
|
414
|
+
而不是报错:没有角色设定也该能算出一个立场。
|
|
415
|
+
"""
|
|
416
|
+
from .character import CharacterSpec, stance as _stance
|
|
417
|
+
|
|
418
|
+
c = self.character or CharacterSpec(name="(未设定)")
|
|
419
|
+
rel = self.relationship.state(profile)
|
|
420
|
+
return _stance(
|
|
421
|
+
c,
|
|
422
|
+
anger=self.anger(profile),
|
|
423
|
+
trust=rel["trust"],
|
|
424
|
+
closeness=rel["closeness"],
|
|
425
|
+
open_grievances=len(self.grievances.open_ones(profile)),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def record_count(self, profile: dict) -> int:
|
|
429
|
+
"""这个人的情绪与关系事件一共有多少条。
|
|
430
|
+
|
|
431
|
+
给"事实抹不掉"一个可见的数字:性格可以让人气消得更快,
|
|
432
|
+
但那些事件一直在库里。
|
|
433
|
+
"""
|
|
434
|
+
row = self.engine._conn().execute(
|
|
435
|
+
"SELECT (SELECT COUNT(*) FROM emotion_events WHERE profile_id=?)"
|
|
436
|
+
" + (SELECT COUNT(*) FROM relationship_events WHERE profile_id=?) c",
|
|
437
|
+
(profile["profile_id"], profile["profile_id"]),
|
|
438
|
+
).fetchone()
|
|
439
|
+
return int(row["c"])
|
|
440
|
+
|
|
441
|
+
def render(self, profile: dict) -> str:
|
|
442
|
+
"""人话版。给主 AI 塞进提示词。
|
|
443
|
+
|
|
444
|
+
刻意把三件事分开写,因为它们**不能互相换算**:
|
|
445
|
+
气消了不代表关系回来了,关系回来了不代表那件事就没了。
|
|
446
|
+
|
|
447
|
+
最后一行是角色倾向。**它不写台词**——只写"会不会先开口、
|
|
448
|
+
嘴上放不放过"。台词留给主 AI,因为同一份倾向可以有很多种说法,
|
|
449
|
+
而写死一句「哼」就是模板了(7.3)。
|
|
450
|
+
"""
|
|
451
|
+
snap = self.snapshot(profile)
|
|
452
|
+
lines = []
|
|
453
|
+
|
|
454
|
+
if snap["mood"]:
|
|
455
|
+
parts = [f"{_NAMES.get(k, k)} {v:.0f}" for k, v in
|
|
456
|
+
sorted(snap["mood"].items(), key=lambda kv: -kv[1])]
|
|
457
|
+
lines.append("现在的心情:" + "、".join(parts))
|
|
458
|
+
else:
|
|
459
|
+
lines.append("现在的心情:平静")
|
|
460
|
+
|
|
461
|
+
rel = snap["relationship"]
|
|
462
|
+
lines.append("跟他的关系:" + "、".join(
|
|
463
|
+
f"{_NAMES.get(k, k)} {v:.0f}" for k, v in rel.items()))
|
|
464
|
+
|
|
465
|
+
if snap["open_grievances"]:
|
|
466
|
+
lines.append("还没说开的事:")
|
|
467
|
+
for g in snap["open_grievances"]:
|
|
468
|
+
lines.append(f" - {g['summary']}")
|
|
469
|
+
|
|
470
|
+
if self.character is not None:
|
|
471
|
+
st = self.stance(profile)
|
|
472
|
+
lines.append("此刻大概会:" + _describe_stance(st))
|
|
473
|
+
return "\n".join(lines)
|
|
474
|
+
|
|
475
|
+
# ---------------------------------------------------------- 复合动作
|
|
476
|
+
def offended(self, profile: dict, *, summary: str, severity: float,
|
|
477
|
+
anger: float, trust_delta: float,
|
|
478
|
+
reason: str,
|
|
479
|
+
evidence_trace_id: str | None = None) -> dict:
|
|
480
|
+
"""被冒犯了:气上来、信任掉、留一条未了结的事。
|
|
481
|
+
|
|
482
|
+
这三件事**必须一起做**,所以合成一个动作。
|
|
483
|
+
少做任何一件都会出现奇怪的状态:
|
|
484
|
+
|
|
485
|
+
只加情绪 → 三十天后气消了,关系自动恢复(假的)
|
|
486
|
+
只掉信任 → 他不知道为什么,也看不到有件事没解决
|
|
487
|
+
只记未了结的事 → 情绪上毫无反应,像一台录音机
|
|
488
|
+
|
|
489
|
+
给了角色卡时,`anger` 和 `trust_delta` 会被**乘**上角色的敏感度
|
|
490
|
+
(6.3:直率角色 +30,温和角色 +10)。这是角色卡唯一能碰的地方——
|
|
491
|
+
它调幅度,不调有没有这件事。
|
|
492
|
+
"""
|
|
493
|
+
c = self.character
|
|
494
|
+
anger_amount = anger * (c.anger_sensitivity if c else 1.0)
|
|
495
|
+
trust_amount = trust_delta * (c.trust_damage if c else 1.0)
|
|
496
|
+
|
|
497
|
+
emo = self.emotion.feel(profile, "anger", anger_amount, reason=reason,
|
|
498
|
+
evidence_trace_id=evidence_trace_id)
|
|
499
|
+
self.emotion.feel(profile, "grievance", severity * 10,
|
|
500
|
+
reason=reason, evidence_trace_id=evidence_trace_id)
|
|
501
|
+
rel = self.relationship.shift(profile, "trust", trust_amount,
|
|
502
|
+
reason=reason,
|
|
503
|
+
evidence_trace_id=evidence_trace_id)
|
|
504
|
+
g = self.grievances.open(profile, summary, severity=severity,
|
|
505
|
+
evidence_trace_id=evidence_trace_id)
|
|
506
|
+
return {"emotion": emo, "relationship": rel, "grievance": g}
|
|
507
|
+
|
|
508
|
+
def repair(self, profile: dict, *, grievance_id: str,
|
|
509
|
+
trust: float = 5.0, closeness: float = 5.0,
|
|
510
|
+
reason: str, forgiven: bool = False,
|
|
511
|
+
evidence_trace_id: str | None = None) -> dict:
|
|
512
|
+
"""把话说开了:关掉那件事,关系回暖一点。
|
|
513
|
+
|
|
514
|
+
注意这里是**唯一**能让 `closeness` 回到基线以上的路径。
|
|
515
|
+
时间做不到,别的正向事件也做不到——
|
|
516
|
+
方案必测场景 F 说的就是这个:未修复关系前不自动回复亲密。
|
|
517
|
+
|
|
518
|
+
角色卡在这里调的是**回流速度**(7.2「关系修复的偏好」):
|
|
519
|
+
傲娇只回八成,温柔回一点三倍。同样一句道歉,
|
|
520
|
+
在不同的人那里分量不一样——但**都要有那句话**。
|
|
521
|
+
"""
|
|
522
|
+
c = self.character
|
|
523
|
+
trust_amount = trust * (c.trust_repair if c else 1.0)
|
|
524
|
+
closeness_amount = closeness * (c.trust_repair if c else 1.0)
|
|
525
|
+
|
|
526
|
+
g = self.grievances.close(grievance_id, forgiven=forgiven)
|
|
527
|
+
self.relationship.shift(profile, "trust", trust_amount, reason=reason,
|
|
528
|
+
is_repair=True,
|
|
529
|
+
evidence_trace_id=evidence_trace_id)
|
|
530
|
+
self.relationship.shift(profile, "closeness", closeness_amount,
|
|
531
|
+
reason=reason, is_repair=True,
|
|
532
|
+
evidence_trace_id=evidence_trace_id)
|
|
533
|
+
# 气消一点,但**不等于消气**——那要看时间。
|
|
534
|
+
self.emotion.feel(profile, "anger", -10.0, reason=reason,
|
|
535
|
+
evidence_trace_id=evidence_trace_id)
|
|
536
|
+
self.emotion.feel(profile, "grievance", -20.0, reason=reason,
|
|
537
|
+
evidence_trace_id=evidence_trace_id)
|
|
538
|
+
return {"grievance": g}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _rate(emotion: str, half_life_days: float | None = None) -> float:
|
|
542
|
+
"""半衰期换算成衰减系数:λ = ln2 / 半衰期。
|
|
543
|
+
|
|
544
|
+
`half_life_days` 由角色卡给(7.2「衰减速度」)。
|
|
545
|
+
给负数或 0 视为无效,退回默认——宁可让角色卡不生效,
|
|
546
|
+
也不要让一个笔误把情绪变成永不消退。
|
|
547
|
+
"""
|
|
548
|
+
half = half_life_days if half_life_days and half_life_days > 0 \
|
|
549
|
+
else _HALF_LIFE_DAYS.get(emotion, 1.0)
|
|
550
|
+
return math.log(2) / half
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _clamp(value: float, low: float, high: float) -> float:
|
|
554
|
+
return max(low, min(high, value))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
_TONE_WORDS = {
|
|
558
|
+
"sharp": "话里带刺",
|
|
559
|
+
"cold": "语气平淡、不多说",
|
|
560
|
+
"warm": "语气缓和",
|
|
561
|
+
"normal": "语气正常",
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _describe_stance(st) -> str:
|
|
566
|
+
"""把倾向写成一句人话。**只写行为,不写台词。**"""
|
|
567
|
+
parts = [_TONE_WORDS.get(st.tone, st.tone)]
|
|
568
|
+
parts.append("会先开口" if st.speak_first else "不会先开口")
|
|
569
|
+
if st.will_mention_it:
|
|
570
|
+
parts.append("会提那件事")
|
|
571
|
+
if st.needs_apology:
|
|
572
|
+
parts.append("在等一个解释")
|
|
573
|
+
return ",".join(parts)
|