dsh-biocore 0.3.2 → 0.4.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/lib/index.js +30 -1
- package/lib/modules/standby.js +61 -0
- package/package.json +1 -1
- package/python/biocore_memory/store/tiering.py +14 -3
- package/python/biocore_model/sanitize.py +6 -1
- package/python/biocore_model/tests/test_cache_series.py +137 -0
- package/python/biocore_model/tests/test_lifecycle.py +153 -0
- package/python/biocore_model/tests/test_retrieval_plus.py +143 -0
- package/python/biocore_model/tests/test_standby.py +199 -0
- package/python/biocore_runtime/__init__.py +3 -0
- package/python/biocore_runtime/cache_stats.py +161 -0
- package/python/biocore_runtime/lifecycle.py +233 -0
- package/python/biocore_runtime/pipeline.py +134 -3
- package/python/biocore_runtime/retrieval_plus.py +352 -0
- package/python/biocore_runtime/standby.py +125 -18
- package/python/data/signals.json +3266 -206
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-215: standby initiative -- outbox only, default off."""
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from biocore_runtime import pipeline, standby
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def noon(offset_minutes=0):
|
|
13
|
+
local = time.localtime()
|
|
14
|
+
stamp = time.mktime((local.tm_year, local.tm_mon, local.tm_mday,
|
|
15
|
+
12, 0, 0, 0, 0, -1))
|
|
16
|
+
return stamp + offset_minutes * 60
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ok_transport(captured=None):
|
|
20
|
+
def call(url, payload, timeout, headers):
|
|
21
|
+
if captured is not None:
|
|
22
|
+
captured.append(payload)
|
|
23
|
+
body = json.dumps({"choices": [{"message": {"content": "最近还好吗?"}}]})
|
|
24
|
+
return 200, body.encode("utf-8")
|
|
25
|
+
return call
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fail_transport(url, payload, timeout, headers):
|
|
29
|
+
raise OSError("network down")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Case(unittest.TestCase):
|
|
33
|
+
def setUp(self):
|
|
34
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
35
|
+
self.data_dir = Path(self.tmp.name) / "data"
|
|
36
|
+
self.store = self.data_dir / "memory"
|
|
37
|
+
(self.store / "sessions" / "s1").mkdir(parents=True)
|
|
38
|
+
self.cfg = {
|
|
39
|
+
"standby_enabled": True,
|
|
40
|
+
"standby_store": str(self.store),
|
|
41
|
+
"standby_session_id": "s1",
|
|
42
|
+
"standby_model_base_url": "http://127.0.0.1:9",
|
|
43
|
+
"standby_model": "test-model",
|
|
44
|
+
"standby_active_hours": "09:00-23:00",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def tearDown(self):
|
|
48
|
+
self.tmp.cleanup()
|
|
49
|
+
|
|
50
|
+
def outbox(self):
|
|
51
|
+
path = self.store / "proactive" / "outbox.jsonl"
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return []
|
|
54
|
+
return [json.loads(line) for line in
|
|
55
|
+
path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
56
|
+
|
|
57
|
+
def seed_state(self, **values):
|
|
58
|
+
path = self.store / "proactive" / "state.json"
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
path.write_text(json.dumps(values), encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
def session_state(self, **values):
|
|
63
|
+
pipeline._save_session_state(str(self.store), "s1", values)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GatesTest(Case):
|
|
67
|
+
def test_default_off(self):
|
|
68
|
+
result = standby.tick(now=noon(), config={"standby_store": str(self.store)})
|
|
69
|
+
self.assertEqual(result["reason"], "disabled")
|
|
70
|
+
self.assertEqual(self.outbox(), [])
|
|
71
|
+
|
|
72
|
+
def test_outside_active_hours(self):
|
|
73
|
+
early = noon(-12 * 60) # 00:00
|
|
74
|
+
result = standby.tick(now=early, config=self.cfg,
|
|
75
|
+
transport=ok_transport())
|
|
76
|
+
self.assertEqual(result["reason"], "outside_active_hours")
|
|
77
|
+
self.assertEqual(self.outbox(), [])
|
|
78
|
+
|
|
79
|
+
def test_daily_cap(self):
|
|
80
|
+
now = noon()
|
|
81
|
+
self.seed_state(sent=[now - 60, now - 120, now - 180],
|
|
82
|
+
last_attempt_ts=0.0, last_sent_ts=0.0)
|
|
83
|
+
result = standby.tick(now=now, config=self.cfg,
|
|
84
|
+
transport=ok_transport())
|
|
85
|
+
self.assertEqual(result["reason"], "daily_cap")
|
|
86
|
+
|
|
87
|
+
def test_cooldown_blocks(self):
|
|
88
|
+
now = noon()
|
|
89
|
+
self.seed_state(last_attempt_ts=now - 10 * 60)
|
|
90
|
+
result = standby.tick(now=now, config=self.cfg,
|
|
91
|
+
transport=ok_transport())
|
|
92
|
+
self.assertEqual(result["reason"], "cooldown")
|
|
93
|
+
|
|
94
|
+
def test_no_reply_streak_stops(self):
|
|
95
|
+
now = noon()
|
|
96
|
+
self.seed_state(awaiting_reply=True, no_reply_streak=1,
|
|
97
|
+
last_sent_ts=now - 3 * 3600)
|
|
98
|
+
self.session_state(last_human_ts=now - 5 * 3600)
|
|
99
|
+
result = standby.tick(now=now, config=self.cfg,
|
|
100
|
+
transport=ok_transport())
|
|
101
|
+
self.assertEqual(result["reason"], "no_reply_streak")
|
|
102
|
+
self.assertEqual(self.outbox(), [])
|
|
103
|
+
|
|
104
|
+
def test_reply_resets_streak(self):
|
|
105
|
+
now = noon()
|
|
106
|
+
self.seed_state(awaiting_reply=True, no_reply_streak=1,
|
|
107
|
+
last_sent_ts=now - 3 * 3600,
|
|
108
|
+
last_attempt_ts=now - 3 * 3600)
|
|
109
|
+
self.session_state(last_human_ts=now - 3600)
|
|
110
|
+
result = standby.tick(now=now, config=self.cfg,
|
|
111
|
+
transport=ok_transport())
|
|
112
|
+
self.assertEqual(result["reason"], "ok")
|
|
113
|
+
self.assertEqual(len(self.outbox()), 1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class OutputTest(Case):
|
|
117
|
+
def test_outbox_row_shape(self):
|
|
118
|
+
now = noon()
|
|
119
|
+
result = standby.tick(now=now, config=self.cfg,
|
|
120
|
+
transport=ok_transport())
|
|
121
|
+
self.assertEqual(result["reason"], "ok")
|
|
122
|
+
row = self.outbox()[0]
|
|
123
|
+
for key in ("ts", "text", "reason", "state_snapshot", "prompt",
|
|
124
|
+
"fallback"):
|
|
125
|
+
self.assertIn(key, row)
|
|
126
|
+
self.assertEqual(row["text"], "最近还好吗?")
|
|
127
|
+
self.assertFalse(row["fallback"])
|
|
128
|
+
|
|
129
|
+
def test_prompt_has_full_persona_memories_digest(self):
|
|
130
|
+
long_persona = "你是安吉拉。" + "必须保持礼貌、精确、克制。" * 40
|
|
131
|
+
(self.store / "persona_profile.json").write_text(
|
|
132
|
+
json.dumps({"persona_text": long_persona}), encoding="utf-8")
|
|
133
|
+
(self.store / "sessions" / "s1" / "memory.json").write_text(
|
|
134
|
+
json.dumps({"entries": [
|
|
135
|
+
{"id": "a", "text": "苹果很甜", "ts": 3},
|
|
136
|
+
{"id": "b", "text": "明天体检", "ts": 2},
|
|
137
|
+
{"id": "c", "text": "咖啡少糖", "ts": 1}]}), encoding="utf-8")
|
|
138
|
+
self.session_state(last_user_text="今天有点累", last_human_ts=noon() - 3600)
|
|
139
|
+
prompt = standby.build_prompt({}, ["苹果很甜", "明天体检", "咖啡少糖"],
|
|
140
|
+
long_persona, digest="今天有点累")
|
|
141
|
+
self.assertIn(long_persona, prompt)
|
|
142
|
+
self.assertIn("最近记得:", prompt)
|
|
143
|
+
self.assertIn("苹果很甜", prompt)
|
|
144
|
+
self.assertIn("上次对话:今天有点累", prompt)
|
|
145
|
+
captured = []
|
|
146
|
+
standby.tick(now=noon(), config=self.cfg,
|
|
147
|
+
transport=ok_transport(captured))
|
|
148
|
+
tick_prompt = captured[0]["messages"][0]["content"]
|
|
149
|
+
self.assertIn("最近记得:", tick_prompt)
|
|
150
|
+
self.assertIn("上次对话:今天有点累", tick_prompt)
|
|
151
|
+
|
|
152
|
+
def test_missing_model_uses_template(self):
|
|
153
|
+
cfg = dict(self.cfg)
|
|
154
|
+
cfg["standby_model"] = ""
|
|
155
|
+
result = standby.tick(now=noon(), config=cfg)
|
|
156
|
+
self.assertEqual(result["reason"], "template_fallback")
|
|
157
|
+
self.assertTrue(result["fallback"])
|
|
158
|
+
self.assertTrue(self.outbox()[0]["text"])
|
|
159
|
+
|
|
160
|
+
def test_transport_failure_uses_template(self):
|
|
161
|
+
result = standby.tick(now=noon(), config=self.cfg,
|
|
162
|
+
transport=fail_transport)
|
|
163
|
+
self.assertEqual(result["reason"], "template_fallback")
|
|
164
|
+
self.assertTrue(self.outbox()[0]["fallback"])
|
|
165
|
+
|
|
166
|
+
def test_second_tick_is_cooldown(self):
|
|
167
|
+
now = noon()
|
|
168
|
+
standby.tick(now=now, config=self.cfg, transport=ok_transport())
|
|
169
|
+
second = standby.tick(now=now + 60, config=self.cfg,
|
|
170
|
+
transport=ok_transport())
|
|
171
|
+
self.assertEqual(second["reason"], "cooldown")
|
|
172
|
+
self.assertEqual(len(self.outbox()), 1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class StateTest(Case):
|
|
176
|
+
def test_summary_and_state_fields(self):
|
|
177
|
+
now = noon()
|
|
178
|
+
standby.tick(now=now, config=self.cfg, transport=ok_transport())
|
|
179
|
+
summary = standby.summary(str(self.store), now=now + 60, enabled=True)
|
|
180
|
+
self.assertTrue(summary["enabled"])
|
|
181
|
+
self.assertEqual(summary["sent_today"], 1)
|
|
182
|
+
self.assertGreater(summary["next_at"], 0)
|
|
183
|
+
self.assertEqual(summary["last_reason"], "ok")
|
|
184
|
+
body = pipeline.dispatch("state", {
|
|
185
|
+
"store": str(self.store), "session_id": "s1",
|
|
186
|
+
"persona_profile": str(Path(self.tmp.name) / "missing.json")})["state"]
|
|
187
|
+
self.assertIn("standby", body)
|
|
188
|
+
for key in ("enabled", "next_at", "pending", "sent_today",
|
|
189
|
+
"last_reason"):
|
|
190
|
+
self.assertIn(key, body["standby"])
|
|
191
|
+
|
|
192
|
+
def test_action_is_gated_by_flag(self):
|
|
193
|
+
out = pipeline.dispatch("standby_tick", {
|
|
194
|
+
"store": str(self.store), "session_id": "s1", "now": noon()})
|
|
195
|
+
self.assertEqual(out["reason"], "disabled")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
unittest.main()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Cache/usage accounting in the core (contract migration 1).
|
|
3
|
+
|
|
4
|
+
Usage events live in <dataDir>/usage_events.jsonl; the core is the only writer.
|
|
5
|
+
The adapter may still read the legacy <dataDir>/cache_stats.json for a week as
|
|
6
|
+
a read-only fallback, but it never writes or recomputes hit_rate.
|
|
7
|
+
|
|
8
|
+
hit_rate = cache_read / (cache_read + cache_write), falling back to
|
|
9
|
+
cache_read / prompt_tokens when no write tokens were reported; clamped to
|
|
10
|
+
[0, 1]. The default window is 7 days and every event is bounded.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
USAGE_FILE = "usage_events.jsonl"
|
|
19
|
+
LEGACY_FILE = "cache_stats.json"
|
|
20
|
+
SCHEMA = 1
|
|
21
|
+
DEFAULT_WINDOW_HOURS = 24 * 7
|
|
22
|
+
MAX_EVENTS = 5000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _root(store):
|
|
26
|
+
if isinstance(store, dict):
|
|
27
|
+
raw = store.get("root") or store.get("store") or store.get("path")
|
|
28
|
+
else:
|
|
29
|
+
raw = store
|
|
30
|
+
text = str(raw or "").strip()
|
|
31
|
+
return Path(text) if text else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _data_dir(store):
|
|
35
|
+
root = _root(store)
|
|
36
|
+
return (root.parent if root.name == "memory" else root) if root else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _num(value, default=0.0):
|
|
40
|
+
try:
|
|
41
|
+
number = float(value)
|
|
42
|
+
except Exception:
|
|
43
|
+
return default
|
|
44
|
+
return number if number == number else default
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def usage_path(store):
|
|
48
|
+
data_dir = _data_dir(store)
|
|
49
|
+
return (data_dir / USAGE_FILE) if data_dir is not None else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def legacy_path(store):
|
|
53
|
+
data_dir = _data_dir(store)
|
|
54
|
+
return (data_dir / LEGACY_FILE) if data_dir is not None else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_events(store):
|
|
58
|
+
path = usage_path(store)
|
|
59
|
+
rows = []
|
|
60
|
+
try:
|
|
61
|
+
if path is None or not path.exists():
|
|
62
|
+
return rows
|
|
63
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
64
|
+
line = line.strip()
|
|
65
|
+
if not line:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
item = json.loads(line)
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
if isinstance(item, dict):
|
|
72
|
+
rows.append(item)
|
|
73
|
+
except Exception:
|
|
74
|
+
return []
|
|
75
|
+
return rows
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def record_event(store, event, now=None):
|
|
79
|
+
"""Append one usage event; bounded to MAX_EVENTS."""
|
|
80
|
+
path = usage_path(store)
|
|
81
|
+
if path is None:
|
|
82
|
+
return None
|
|
83
|
+
stamp = round(float(now or event.get("ts") or time.time()), 3)
|
|
84
|
+
row = {
|
|
85
|
+
"ts": stamp,
|
|
86
|
+
"prompt_tokens": max(0, int(_num(event.get("prompt_tokens")))),
|
|
87
|
+
"output_tokens": max(0, int(_num(event.get("output_tokens")))),
|
|
88
|
+
"cache_read": max(0, int(_num(event.get("cache_read")))),
|
|
89
|
+
"cache_write": max(0, int(_num(event.get("cache_write")))),
|
|
90
|
+
}
|
|
91
|
+
try:
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
rows = _read_events(store)[-(MAX_EVENTS - 1):] + [row]
|
|
94
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
95
|
+
tmp.write_text("\n".join(json.dumps(item, ensure_ascii=False)
|
|
96
|
+
for item in rows) + "\n", encoding="utf-8")
|
|
97
|
+
tmp.replace(path)
|
|
98
|
+
except Exception:
|
|
99
|
+
return None
|
|
100
|
+
return row
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _legacy(store):
|
|
104
|
+
path = legacy_path(store)
|
|
105
|
+
try:
|
|
106
|
+
if path is None or not path.exists():
|
|
107
|
+
return None
|
|
108
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
109
|
+
except Exception:
|
|
110
|
+
return None
|
|
111
|
+
if not isinstance(doc, dict):
|
|
112
|
+
return None
|
|
113
|
+
return {
|
|
114
|
+
"turns": max(0, int(_num(doc.get("turns")))),
|
|
115
|
+
"prompt_tokens": max(0, int(_num(doc.get("prompt_tokens")))),
|
|
116
|
+
"output_tokens": max(0, int(_num(doc.get("output_tokens")))),
|
|
117
|
+
"cache_read": max(0, int(_num(doc.get("cache_read")))),
|
|
118
|
+
"cache_write": max(0, int(_num(doc.get("cache_write")))),
|
|
119
|
+
"hit_rate": round(min(1.0, max(0.0, _num(doc.get("hit_rate")))), 4),
|
|
120
|
+
"updated_at": round(_num(doc.get("updated_at")), 3),
|
|
121
|
+
"source": "legacy_file",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def compute(store, now=None, window_hours=DEFAULT_WINDOW_HOURS):
|
|
126
|
+
"""Aggregate usage events in the window; legacy file only if no events."""
|
|
127
|
+
stamp = float(now or time.time())
|
|
128
|
+
window = max(1.0, _num(window_hours, DEFAULT_WINDOW_HOURS))
|
|
129
|
+
cutoff = stamp - window * 3600.0
|
|
130
|
+
events = [row for row in _read_events(store)
|
|
131
|
+
if _num(row.get("ts"), 0.0) >= cutoff]
|
|
132
|
+
if not events:
|
|
133
|
+
legacy = _legacy(store)
|
|
134
|
+
if legacy is not None:
|
|
135
|
+
legacy["window_hours"] = round(window, 2)
|
|
136
|
+
return legacy
|
|
137
|
+
return {"turns": 0, "prompt_tokens": 0, "output_tokens": 0,
|
|
138
|
+
"cache_read": 0, "cache_write": 0, "hit_rate": 0.0,
|
|
139
|
+
"updated_at": 0.0, "window_hours": round(window, 2),
|
|
140
|
+
"source": "empty"}
|
|
141
|
+
prompt = sum(max(0, int(_num(row.get("prompt_tokens")))) for row in events)
|
|
142
|
+
output = sum(max(0, int(_num(row.get("output_tokens")))) for row in events)
|
|
143
|
+
cache_read = sum(max(0, int(_num(row.get("cache_read")))) for row in events)
|
|
144
|
+
cache_write = sum(max(0, int(_num(row.get("cache_write")))) for row in events)
|
|
145
|
+
denom = cache_read + cache_write
|
|
146
|
+
if prompt > 0:
|
|
147
|
+
hit_rate = cache_read / float(prompt)
|
|
148
|
+
elif denom > 0:
|
|
149
|
+
hit_rate = cache_read / float(denom)
|
|
150
|
+
else:
|
|
151
|
+
hit_rate = 0.0
|
|
152
|
+
hit_rate = round(min(1.0, max(0.0, hit_rate)), 4)
|
|
153
|
+
return {"turns": len(events), "prompt_tokens": prompt,
|
|
154
|
+
"output_tokens": output, "cache_read": cache_read,
|
|
155
|
+
"cache_write": cache_write, "hit_rate": hit_rate,
|
|
156
|
+
"updated_at": round(_num(events[-1].get("ts")), 3),
|
|
157
|
+
"window_hours": round(window, 2), "source": "usage_events"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def summary(store, now=None):
|
|
161
|
+
return compute(store, now=now, window_hours=DEFAULT_WINDOW_HOURS)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-36 + task-78: tier hysteresis, reconsolidation, minimal repair loop.
|
|
3
|
+
|
|
4
|
+
- tier_tick: call after recall; hot stays hot until hits < 3 (task-36).
|
|
5
|
+
- record_recall/reconsolidate: 6h window; recall + new input marks the entry
|
|
6
|
+
regenerated_at and nudges its strength via biocore_model.feedback_memory.
|
|
7
|
+
It never deletes and is idempotent per recall batch (task-78).
|
|
8
|
+
- repair: a blocked boundary arms one repair turn; prompt_block then lowers
|
|
9
|
+
confrontation / prefers repair wording, with a short cooldown (task-78).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from biocore_memory.store import tiering as _tiering
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from biocore_model import feedback_memory as _feedback
|
|
21
|
+
except Exception: # pragma: no cover
|
|
22
|
+
_feedback = None
|
|
23
|
+
|
|
24
|
+
TIERS_FILE = "tiers.json"
|
|
25
|
+
RECALL_FILE = "recall_state.json"
|
|
26
|
+
REPAIR_FILE = "repair.json"
|
|
27
|
+
SCHEMA = 1
|
|
28
|
+
RECONSOLIDATION_WINDOW_S = 6 * 3600.0
|
|
29
|
+
REPAIR_TTL_S = 30 * 60.0
|
|
30
|
+
REPAIR_COOLDOWN_S = 10 * 60.0
|
|
31
|
+
IMPORTANCE_STEP = 0.02
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _root(store):
|
|
35
|
+
if isinstance(store, dict):
|
|
36
|
+
raw = store.get("root") or store.get("store") or store.get("path")
|
|
37
|
+
else:
|
|
38
|
+
raw = store
|
|
39
|
+
text = str(raw or "").strip()
|
|
40
|
+
return Path(text) if text else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _data_dir(store):
|
|
44
|
+
root = _root(store)
|
|
45
|
+
return (root.parent if root.name == "memory" else root) if root else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read(path):
|
|
49
|
+
try:
|
|
50
|
+
if path is None or not path.exists():
|
|
51
|
+
return {}
|
|
52
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
return data if isinstance(data, dict) else {}
|
|
54
|
+
except Exception:
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write(path, doc):
|
|
59
|
+
try:
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
62
|
+
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2),
|
|
63
|
+
encoding="utf-8")
|
|
64
|
+
tmp.replace(path)
|
|
65
|
+
return True
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _num(value, default=0.0):
|
|
71
|
+
try:
|
|
72
|
+
number = float(value)
|
|
73
|
+
except Exception:
|
|
74
|
+
return default
|
|
75
|
+
return number if number == number else default
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------- tiers
|
|
79
|
+
def tier_tick(store, entries, now=None):
|
|
80
|
+
"""Classify the recalled entries with hysteresis; returns counters."""
|
|
81
|
+
root = _root(store)
|
|
82
|
+
if root is None:
|
|
83
|
+
return None
|
|
84
|
+
stamp = round(float(now or time.time()), 3)
|
|
85
|
+
doc = _read(root / TIERS_FILE)
|
|
86
|
+
by_id = doc.get("by_id") if isinstance(doc.get("by_id"), dict) else {}
|
|
87
|
+
counts = {"hot": 0, "warm": 0, "cold": 0}
|
|
88
|
+
entered = int(_num(doc.get("entered"), 0))
|
|
89
|
+
exited = int(_num(doc.get("exited"), 0))
|
|
90
|
+
for entry in entries or []:
|
|
91
|
+
if not isinstance(entry, dict):
|
|
92
|
+
continue
|
|
93
|
+
entry_id = str(entry.get("id") or "")
|
|
94
|
+
if not entry_id:
|
|
95
|
+
continue
|
|
96
|
+
previous = str((by_id.get(entry_id) or {}).get("tier") or "")
|
|
97
|
+
tier = _tiering.tier_of(entry, now=stamp, previous=previous)
|
|
98
|
+
if previous and previous != tier:
|
|
99
|
+
if tier == "hot":
|
|
100
|
+
entered += 1
|
|
101
|
+
elif previous == "hot":
|
|
102
|
+
exited += 1
|
|
103
|
+
by_id[entry_id] = {"tier": tier, "ts": stamp}
|
|
104
|
+
counts[tier] = counts.get(tier, 0) + 1
|
|
105
|
+
doc.update({"schema": SCHEMA, "by_id": by_id, "counts": counts,
|
|
106
|
+
"entered": entered, "exited": exited, "updated_at": stamp})
|
|
107
|
+
_write(root / TIERS_FILE, doc)
|
|
108
|
+
return {"counts": counts, "entered": entered, "exited": exited,
|
|
109
|
+
"total": sum(counts.values()), "updated_at": stamp}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def tier_summary(store):
|
|
113
|
+
doc = _read((_root(store) or Path(".")) / TIERS_FILE)
|
|
114
|
+
counts = doc.get("counts") if isinstance(doc.get("counts"), dict) else {}
|
|
115
|
+
return {"hot": int(_num(counts.get("hot"))),
|
|
116
|
+
"warm": int(_num(counts.get("warm"))),
|
|
117
|
+
"cold": int(_num(counts.get("cold"))),
|
|
118
|
+
"entered": int(_num(doc.get("entered"))),
|
|
119
|
+
"exited": int(_num(doc.get("exited"))),
|
|
120
|
+
"updated_at": _num(doc.get("updated_at"))}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------- reconsolidation
|
|
124
|
+
def record_recall(store, session_id, ids, now=None):
|
|
125
|
+
root = _root(store)
|
|
126
|
+
if root is None:
|
|
127
|
+
return False
|
|
128
|
+
stamp = round(float(now or time.time()), 3)
|
|
129
|
+
doc = {"schema": SCHEMA, "session_id": str(session_id or ""),
|
|
130
|
+
"ids": [str(item) for item in (ids or [])][:12], "ts": stamp}
|
|
131
|
+
return _write(root / RECALL_FILE, doc)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_session_entries(root, session_id):
|
|
135
|
+
path = root / "sessions" / str(session_id or "") / "memory.json"
|
|
136
|
+
doc = _read(path)
|
|
137
|
+
entries = doc.get("entries") if isinstance(doc.get("entries"), list) else []
|
|
138
|
+
return doc, entries
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def reconsolidate(store, now=None):
|
|
142
|
+
"""Within 6h of a recall, a new input marks recalled entries regenerated."""
|
|
143
|
+
root = _root(store)
|
|
144
|
+
if root is None:
|
|
145
|
+
return {"enabled": False, "reason": "no_store"}
|
|
146
|
+
stamp = float(now or time.time())
|
|
147
|
+
state = _read(root / RECALL_FILE)
|
|
148
|
+
ids = [str(item) for item in (state.get("ids") or [])]
|
|
149
|
+
recalled_at = _num(state.get("ts"))
|
|
150
|
+
if not ids or recalled_at <= 0 or stamp - recalled_at > RECONSOLIDATION_WINDOW_S:
|
|
151
|
+
return {"enabled": True, "updated": 0, "skipped": len(ids),
|
|
152
|
+
"reason": "window"}
|
|
153
|
+
doc, entries = _load_session_entries(root, state.get("session_id"))
|
|
154
|
+
updated = 0
|
|
155
|
+
skipped = 0
|
|
156
|
+
for entry in entries:
|
|
157
|
+
if not isinstance(entry, dict) or str(entry.get("id") or "") not in ids:
|
|
158
|
+
continue
|
|
159
|
+
if _num(entry.get("regenerated_at")) >= recalled_at:
|
|
160
|
+
skipped += 1
|
|
161
|
+
continue
|
|
162
|
+
if _feedback is not None:
|
|
163
|
+
result = _feedback.touch([entry], entry.get("id"), stamp,
|
|
164
|
+
kind="reconsolidation")
|
|
165
|
+
touched = (result.get("entries") or [entry])[0]
|
|
166
|
+
entry.update(touched)
|
|
167
|
+
entry["regenerated_at"] = round(stamp, 3)
|
|
168
|
+
entry["reconsolidation_count"] = int(
|
|
169
|
+
_num(entry.get("reconsolidation_count"))) + 1
|
|
170
|
+
entry["importance"] = round(min(1.0, max(0.0,
|
|
171
|
+
_num(entry.get("importance"), 0.5) + IMPORTANCE_STEP)), 4)
|
|
172
|
+
updated += 1
|
|
173
|
+
if updated:
|
|
174
|
+
_write(root / "sessions" / str(state.get("session_id") or "")
|
|
175
|
+
/ "memory.json", doc)
|
|
176
|
+
return {"enabled": True, "updated": updated, "skipped": skipped,
|
|
177
|
+
"ids": ids[:12], "ts": round(stamp, 3)}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def reconsolidation_summary(store):
|
|
181
|
+
root = _root(store)
|
|
182
|
+
if root is None:
|
|
183
|
+
return {"ids": [], "recalled_at": 0.0, "window_s": RECONSOLIDATION_WINDOW_S}
|
|
184
|
+
state = _read(root / RECALL_FILE)
|
|
185
|
+
return {"ids": list(state.get("ids") or [])[:5],
|
|
186
|
+
"recalled_at": _num(state.get("ts")),
|
|
187
|
+
"window_s": RECONSOLIDATION_WINDOW_S}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------- repair
|
|
191
|
+
def arm_repair(store, reason="", now=None):
|
|
192
|
+
root = _root(store)
|
|
193
|
+
if root is None:
|
|
194
|
+
return False
|
|
195
|
+
stamp = float(now or time.time())
|
|
196
|
+
doc = _read(root / REPAIR_FILE)
|
|
197
|
+
if stamp < _num(doc.get("cooldown_until")):
|
|
198
|
+
return False
|
|
199
|
+
doc = {"schema": SCHEMA, "armed": True, "reason": str(reason or "")[:40],
|
|
200
|
+
"armed_at": round(stamp, 3),
|
|
201
|
+
"expires_at": round(stamp + REPAIR_TTL_S, 3),
|
|
202
|
+
"used_at": 0.0,
|
|
203
|
+
"cooldown_until": round(stamp + REPAIR_TTL_S + REPAIR_COOLDOWN_S, 3)}
|
|
204
|
+
return _write(root / REPAIR_FILE, doc)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def repair_lines(store, now=None):
|
|
208
|
+
"""Wording constraints for the next reply; one-shot per arming."""
|
|
209
|
+
root = _root(store)
|
|
210
|
+
if root is None:
|
|
211
|
+
return []
|
|
212
|
+
stamp = float(now or time.time())
|
|
213
|
+
doc = _read(root / REPAIR_FILE)
|
|
214
|
+
if not doc.get("armed") or _num(doc.get("used_at")) > 0:
|
|
215
|
+
return []
|
|
216
|
+
if stamp > _num(doc.get("expires_at")):
|
|
217
|
+
doc["armed"] = False
|
|
218
|
+
_write(root / REPAIR_FILE, doc)
|
|
219
|
+
return []
|
|
220
|
+
doc["used_at"] = round(stamp, 3)
|
|
221
|
+
doc["armed"] = False
|
|
222
|
+
_write(root / REPAIR_FILE, doc)
|
|
223
|
+
return ["先修复关系:承认刚才越界或语气不对,不辩解。",
|
|
224
|
+
"降低对抗:句子短、语气软,先问对方现在的感受。"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def repair_state(store):
|
|
228
|
+
doc = _read((_root(store) or Path(".")) / REPAIR_FILE)
|
|
229
|
+
return {"armed": bool(doc.get("armed")),
|
|
230
|
+
"reason": str(doc.get("reason") or ""),
|
|
231
|
+
"armed_at": _num(doc.get("armed_at")),
|
|
232
|
+
"used_at": _num(doc.get("used_at")),
|
|
233
|
+
"cooldown_until": _num(doc.get("cooldown_until"))}
|