dsh-biocore 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-biocore",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "BioCore DSH UI: per-session state tab, standalone instrument panel, /api/biocore/state, session/event one_turn updates, /bio sync.",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,167 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-225 (5b): P0 memory extensions + expression modules."""
3
+ import json
4
+ import tempfile
5
+ import time
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from biocore_model import (entity_graph, entity_resolve, expr_template,
10
+ graph_extract, graph_recall, layered_memory,
11
+ memory_dual, prompt_rules, prospective,
12
+ reconstruction, template_expr, topic_control,
13
+ turn_memory, voice_bank)
14
+ from biocore_runtime import extensions, pipeline
15
+
16
+
17
+ def entry(i, text, ts=1700000000.0):
18
+ return {"id": i, "text": text, "role": "user", "ts": ts,
19
+ "importance": 0.5, "hits": 1}
20
+
21
+
22
+ class Case(unittest.TestCase):
23
+ def setUp(self):
24
+ self.tmp = tempfile.TemporaryDirectory()
25
+ self.data_dir = Path(self.tmp.name) / "data"
26
+ self.store = self.data_dir / "memory"
27
+ (self.store / "sessions" / "s1").mkdir(parents=True)
28
+
29
+ def tearDown(self):
30
+ self.tmp.cleanup()
31
+
32
+
33
+ class GraphTest(Case):
34
+ def test_entity_graph_build_search(self):
35
+ graph, _ = extensions._entity_graph([
36
+ entry("a", "苹果 很甜"), entry("b", "苹果 咖啡")])
37
+ self.assertGreaterEqual(len(graph.get("entities") or []), 1)
38
+ hits = entity_graph.search(graph, "苹果", limit=4)
39
+ self.assertTrue(hits)
40
+
41
+ def test_entity_resolve(self):
42
+ self.assertEqual(entity_resolve.normalize("苹果 甜"), "苹果甜")
43
+ self.assertGreaterEqual(entity_resolve.similarity("苹果", "苹果"), 0.9)
44
+
45
+ def test_graph_extract(self):
46
+ self.assertEqual(graph_extract.canonical_type("人物"), "person")
47
+ self.assertGreater(graph_extract.when_to_ts("明天", 1000.0), 1000.0)
48
+
49
+ def test_graph_recall_window(self):
50
+ window = graph_recall.detect_window("上周")
51
+ self.assertNotEqual(window, None)
52
+
53
+ def test_graph_hits_extra(self):
54
+ rows = [entry("a", "苹果 很甜"), entry("b", "苹果 咖啡"),
55
+ entry("c", "香蕉")]
56
+ hits, stats = extensions.graph_hits(rows, "苹果")
57
+ self.assertGreaterEqual(stats["hits"], 0)
58
+ self.assertIsInstance(hits, list)
59
+
60
+
61
+ class MemoryExtTest(Case):
62
+ def test_layered_select(self):
63
+ result = layered_memory.select([entry("a", "早上的事"), entry("b", "刚才的事")],
64
+ recalled_ids=["b"])
65
+ self.assertIn("resident", result)
66
+ self.assertIn("retrieval", result)
67
+ self.assertTrue(result["resident"] or result["retrieval"])
68
+
69
+ def test_memory_dual_score(self):
70
+ e = memory_dual.encode("苹果", 1700000000.0, arousal=0.5)
71
+ self.assertGreater(memory_dual.score(e, 1700000100.0), 0)
72
+ self.assertTrue(memory_dual.select([e], 1700000100.0, limit=2))
73
+
74
+ def test_prospective_due(self):
75
+ now = time.time()
76
+ rec = prospective.entry("his_event", "体检", now - 10)
77
+ items, changed = prospective.add([], rec)
78
+ self.assertTrue(changed)
79
+ due = prospective.due_now(items, now, window_s=60)
80
+ self.assertEqual(len(due), 1)
81
+ self.assertTrue(prospective.build_hint(due[0], now))
82
+ self.assertEqual(prospective.stats(items)["by_status"]["pending"], 1)
83
+
84
+ def test_reconstruction(self):
85
+ out = reconstruction.conserve([entry("a", "旧的记忆")])
86
+ self.assertIn("total", out)
87
+
88
+ def test_turn_memory(self):
89
+ rows = turn_memory.build([{"entry_id": "a", "text": "记得喝水",
90
+ "source": "recall"}])
91
+ self.assertEqual(len(rows), 1)
92
+ record = turn_memory.build_record(rows, reply="好", ts=10.0)
93
+ self.assertTrue(turn_memory.render_receipt(record))
94
+
95
+ def test_recall_graph_expansion(self):
96
+ rows = [entry("a", "苹果 很甜", 1700000001.0),
97
+ entry("b", "苹果 咖啡", 1700000002.0),
98
+ entry("c", "香蕉 黄", 1700000003.0)]
99
+ (self.store / "sessions" / "s1" / "memory.json").write_text(
100
+ json.dumps({"entries": rows}), encoding="utf-8")
101
+ out = pipeline.inject_block("s1", str(self.store), query="苹果 咖啡",
102
+ mode="query", top_k=3)
103
+ self.assertGreaterEqual(len(out["ids"]), 2)
104
+ self.assertIn("b", out["ids"])
105
+
106
+
107
+ class ExpressionTest(Case):
108
+ def test_expr_template(self):
109
+ out = expr_template.build_reply({"v": 0.2}, {"trust": 0.5})
110
+ self.assertIsInstance(out, dict)
111
+
112
+ def test_prompt_rules(self):
113
+ text = prompt_rules.render_rules(affect={"valence": -0.4, "stress": 0.7},
114
+ relation={"closeness": 0.5},
115
+ certainty=0.7, soft_target=12,
116
+ soft_max=30)
117
+ self.assertIsInstance(text, str)
118
+ self.assertTrue(text)
119
+
120
+ def test_template_expr(self):
121
+ self.assertEqual(template_expr.render("你好 {{name}}", {"name": "主管"}),
122
+ "你好 主管")
123
+
124
+ def test_topic_control(self):
125
+ result = topic_control.topic_control(turns=[], state={"v": 0.0},
126
+ relationship={"trust": 0.5})
127
+ self.assertIsInstance(result, dict)
128
+ self.assertTrue("tendency" in result or "holder" in result)
129
+
130
+ def test_voice_bank(self):
131
+ bank = [{"id": "v1", "phrase": "嗯。", "weight": 1.0,
132
+ "when": {"v_min": -1, "v_max": 1, "tension_min": 0,
133
+ "tension_max": 1}, "cooldown_s": 0}]
134
+ picked = voice_bank.pick({"v": 0.1, "tension": 0.2},
135
+ {"stage": "normal"}, [], time.time(),
136
+ entries=bank)
137
+ self.assertTrue(picked)
138
+
139
+ def test_expression_block(self):
140
+ block = extensions.expression_block(
141
+ str(self.store), {"core": {"v": -0.2, "stress": 0.7}},
142
+ {"trust": 0.5, "closeness": 0.4, "stage": "normal"},
143
+ soft_target=12, soft_max=30)
144
+ self.assertTrue(block["rules"] or block["template"])
145
+ self.assertIn("template_name", block)
146
+
147
+
148
+ class StateTest(Case):
149
+ def test_state_exposes_extensions(self):
150
+ rows = [entry("a", "苹果 很甜"), entry("b", "香蕉")]
151
+ (self.store / "sessions" / "s1" / "memory.json").write_text(
152
+ json.dumps({"entries": rows}), encoding="utf-8")
153
+ pipeline.dispatch("turn_end", {
154
+ "store": str(self.store), "session_id": "s1",
155
+ "user_text": "苹果很甜", "assistant_text": "嗯。",
156
+ "capture": False,
157
+ "persona_profile": str(Path(self.tmp.name) / "missing.json")})
158
+ body = pipeline.dispatch("state", {
159
+ "store": str(self.store), "session_id": "s1",
160
+ "persona_profile": str(Path(self.tmp.name) / "missing.json")})["state"]
161
+ self.assertIn("extensions", body)
162
+ self.assertIn("graph", body["extensions"])
163
+ self.assertIn("layered", body["extensions"])
164
+
165
+
166
+ if __name__ == "__main__":
167
+ unittest.main()
@@ -0,0 +1,106 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-224 (5a): the 17 P0 modules are evaluated and exposed via /state."""
3
+ import json
4
+ import tempfile
5
+ import time
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from biocore_runtime import pipeline, signals
10
+
11
+
12
+ class SignalsCase(unittest.TestCase):
13
+ def setUp(self):
14
+ self.tmp = tempfile.TemporaryDirectory()
15
+ self.data_dir = Path(self.tmp.name) / "data"
16
+ self.store = self.data_dir / "memory"
17
+ (self.store / "sessions").mkdir(parents=True)
18
+ self.state = {"core": {"v": 0.2, "a": 0.6, "stress": 0.4,
19
+ "social": 0.4, "energy": 0.7}}
20
+ self.rel = {"trust": 0.55, "closeness": 0.5, "conflict": 0.1,
21
+ "days_known": 12}
22
+ self.out = signals.evaluate(self.store, "s1",
23
+ user_text="我们约好明天见面,谢谢你",
24
+ assistant_text="好。",
25
+ state=self.state, relationship=self.rel,
26
+ now=time.time())
27
+
28
+ def tearDown(self):
29
+ self.tmp.cleanup()
30
+
31
+ def module_ok(self, group, name):
32
+ row = self.out[group][name]
33
+ self.assertTrue(row.get("enabled"), row)
34
+ return row
35
+
36
+ def test_affective_labor(self):
37
+ self.module_ok("perception", "affective_labor")
38
+
39
+ def test_compassion_fatigue(self):
40
+ self.module_ok("perception", "compassion_fatigue")
41
+
42
+ def test_connection_bids(self):
43
+ self.module_ok("perception", "connection_bids")
44
+
45
+ def test_defense_mechanisms(self):
46
+ self.module_ok("perception", "defense_mechanisms")
47
+
48
+ def test_emotion_geometry(self):
49
+ self.module_ok("perception", "emotion_geometry")
50
+
51
+ def test_needs_events(self):
52
+ self.module_ok("perception", "needs_events")
53
+
54
+ def test_social_pain(self):
55
+ self.module_ok("perception", "social_pain")
56
+
57
+ def test_social_repair(self):
58
+ self.module_ok("perception", "social_repair")
59
+
60
+ def test_commitment_extractor(self):
61
+ row = self.module_ok("relationship", "commitment_extractor")
62
+ self.assertIsInstance(row.get("value"), dict)
63
+
64
+ def test_relation_stage(self):
65
+ self.module_ok("relationship", "relation_stage")
66
+
67
+ def test_relationship_stage(self):
68
+ self.module_ok("relationship", "relationship_stage")
69
+
70
+ def test_repair_after_boundary(self):
71
+ self.module_ok("relationship", "repair_after_boundary")
72
+
73
+ def test_metacog(self):
74
+ self.module_ok("self", "metacog")
75
+
76
+ def test_possible_selves(self):
77
+ self.module_ok("self", "possible_selves")
78
+
79
+ def test_self_interest(self):
80
+ self.module_ok("self", "self_interest")
81
+
82
+ def test_self_verification(self):
83
+ self.module_ok("self", "self_verification")
84
+
85
+ def test_counts_and_sidecar(self):
86
+ self.assertEqual(self.out["counts"]["perception"], 8)
87
+ self.assertEqual(self.out["counts"]["relationship"], 4)
88
+ self.assertEqual(self.out["counts"]["self"], 4)
89
+ doc = json.loads((self.data_dir / "signals.json").read_text(encoding="utf-8"))
90
+ self.assertEqual(doc["latest"]["session_id"], "s1")
91
+
92
+ def test_state_exposes_signals(self):
93
+ pipeline.dispatch("turn_start", {
94
+ "store": str(self.store), "session_id": "s1",
95
+ "user_text": "你好", "capture": False,
96
+ "persona_profile": str(Path(self.tmp.name) / "missing.json")})
97
+ body = pipeline.dispatch("state", {
98
+ "store": str(self.store), "session_id": "s1",
99
+ "persona_profile": str(Path(self.tmp.name) / "missing.json")})["state"]
100
+ self.assertIn("signals", body)
101
+ self.assertTrue(body["signals"]["enabled"]["metacog"])
102
+ self.assertEqual(body["signals"]["counts"]["self"], 4)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main()
@@ -0,0 +1,317 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-225 (5b): P0 memory extensions + expression extensions for the core.
3
+
4
+ Recall side (all optional, failure -> current lexical path):
5
+ entity_graph / entity_resolve / graph_extract / graph_recall lightweight
6
+ entity graph over memory entries; query entities pull neighbour entries
7
+ layered_memory resident vs retrieval tiers for the injected rows
8
+ memory_dual decayed dual score for ordering
9
+ prospective commitments in <dataDir>/prospective.json + due hints
10
+ reconstruction reconstruction drift summary (read-only)
11
+ turn_memory receipt rows for the last recall batch
12
+
13
+ Expression side (wording constraints only; never changes facts):
14
+ expr_template, prompt_rules, template_expr, topic_control, voice_bank
15
+
16
+ No LLM calls. All sidecar writes are atomic and bounded.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from biocore_model import (entity_graph as _graph,
25
+ entity_resolve as _resolve,
26
+ graph_extract as _extract,
27
+ layered_memory as _layered,
28
+ memory_dual as _dual,
29
+ prospective as _prospective,
30
+ reconstruction as _reconstruct,
31
+ turn_memory as _turn_memory,
32
+ expr_template as _expr_template,
33
+ prompt_rules as _prompt_rules,
34
+ template_expr as _template,
35
+ topic_control as _topic,
36
+ voice_bank as _voice)
37
+
38
+ PROSPECTIVE_FILE = "prospective.json"
39
+ EXTENSIONS_FILE = "extensions.json"
40
+ VOICE_FILE = "voice_bank.json"
41
+ SCHEMA = 1
42
+
43
+
44
+ def _root(store):
45
+ if isinstance(store, dict):
46
+ raw = store.get("root") or store.get("store") or store.get("path")
47
+ else:
48
+ raw = store
49
+ text = str(raw or "").strip()
50
+ return Path(text) if text else None
51
+
52
+
53
+ def _data_dir(store):
54
+ root = _root(store)
55
+ return (root.parent if root.name == "memory" else root) if root else None
56
+
57
+
58
+ def _read(path, fallback):
59
+ try:
60
+ if path is None or not Path(path).exists():
61
+ return fallback
62
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
63
+ return data if isinstance(data, dict) else fallback
64
+ except Exception:
65
+ return fallback
66
+
67
+
68
+ def _write(path, doc):
69
+ try:
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ tmp = path.with_name(path.name + ".tmp")
72
+ tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2),
73
+ encoding="utf-8")
74
+ tmp.replace(path)
75
+ return True
76
+ except Exception:
77
+ return False
78
+
79
+
80
+ def _tokens(text):
81
+ import re
82
+ lowered = str(text or "").lower()
83
+ out = re.findall(r"[a-z0-9_]{3,}", lowered)
84
+ for run in re.findall(r"[\u4e00-\u9fff]+", lowered):
85
+ out.extend(run[index:index + 2] for index in range(len(run) - 1))
86
+ return out
87
+
88
+
89
+ def _entity_graph(entries):
90
+ """Tiny in-memory graph: every concept token is a node, co-occurrence an edge."""
91
+ graph = _graph.empty_graph()
92
+ names = {}
93
+ for entry in entries or []:
94
+ if not isinstance(entry, dict):
95
+ continue
96
+ tokens = list(dict.fromkeys(_tokens(entry.get("text"))))
97
+ for token in tokens[:12]:
98
+ names.setdefault(token, entry)
99
+ for left in tokens[:8]:
100
+ for right in tokens[:8]:
101
+ if left == right:
102
+ continue
103
+ try:
104
+ updated = _graph.upsert_edge(
105
+ graph, left, right, "co_occurs",
106
+ ts=float(entry.get("ts") or 0.0))
107
+ graph = updated[0] if isinstance(updated, tuple) else updated
108
+ except Exception:
109
+ pass
110
+ for token in names:
111
+ try:
112
+ updated = _graph.upsert_entity(graph, token, "topic")
113
+ graph = updated[0] if isinstance(updated, tuple) else updated
114
+ except Exception:
115
+ pass
116
+ return graph, names
117
+
118
+
119
+ def graph_hits(entries, query, exclude=None):
120
+ """Neighbour entry ids for the query's entities (graph recall)."""
121
+ exclude = set(exclude or [])
122
+ graph, by_name = _entity_graph(entries)
123
+ hits = []
124
+ seen = set()
125
+ try:
126
+ matches = _graph.search(graph, query, limit=6)
127
+ except Exception:
128
+ matches = []
129
+ for item in matches or []:
130
+ name = item.get("name") if isinstance(item, dict) else item
131
+ entry = by_name.get(str(name or ""))
132
+ entry_id = str((entry or {}).get("id") or "")
133
+ if entry_id and entry_id not in exclude and entry_id not in seen:
134
+ seen.add(entry_id)
135
+ hits.append(entry)
136
+ stats = {"entities": len(graph.get("entities") or []),
137
+ "edges": len(graph.get("edges") or []),
138
+ "hits": len(hits)}
139
+ try:
140
+ stats.update(_graph.stats(graph))
141
+ except Exception:
142
+ pass
143
+ return hits, stats
144
+
145
+
146
+ def expand_recall(store, entries, query, picked_ids, now=None):
147
+ """Add graph neighbours / dual-score rows; never raises."""
148
+ stamp = float(now or time.time())
149
+ picked_ids = [str(item) for item in (picked_ids or [])]
150
+ hits, graph_stats = graph_hits(entries, query, exclude=picked_ids)
151
+ extra = []
152
+ for entry in hits[:3]:
153
+ if str(entry.get("id") or "") not in picked_ids:
154
+ extra.append(entry)
155
+ dual_rows = []
156
+ try:
157
+ ordered = _dual.select(entries, stamp, limit=max(4, len(picked_ids) + 2))
158
+ dual_rows = [entry for entry in ordered
159
+ if str(entry.get("id") or "") not in picked_ids]
160
+ except Exception:
161
+ dual_rows = []
162
+ for entry in dual_rows[:2]:
163
+ if entry not in extra:
164
+ extra.append(entry)
165
+ return {
166
+ "extra": extra,
167
+ "graph": graph_stats,
168
+ "dual": {"rows": len(dual_rows)},
169
+ }
170
+
171
+
172
+ def layered_view(entries, picked_ids):
173
+ try:
174
+ result = _layered.select(entries, recalled_ids=picked_ids) or {}
175
+ except Exception:
176
+ result = {}
177
+ resident = result.get("resident") or []
178
+ retrieval = result.get("retrieval") or []
179
+ return {"selected": len(resident) + len(retrieval),
180
+ "resident": len(resident), "retrieval": len(retrieval)}
181
+
182
+
183
+ def prospective_view(store, now=None):
184
+ data_dir = _data_dir(store)
185
+ path = (data_dir / PROSPECTIVE_FILE) if data_dir else None
186
+ doc = _read(path, {"items": []})
187
+ items = [row for row in (doc.get("items") or []) if isinstance(row, dict)]
188
+ stamp = float(now or time.time())
189
+ due = []
190
+ try:
191
+ due = _prospective.due_now(items, stamp, window_s=3600.0)
192
+ except Exception:
193
+ due = []
194
+ hint = ""
195
+ if due:
196
+ try:
197
+ hint = _prospective.build_hint(due[0], stamp)
198
+ except Exception:
199
+ hint = ""
200
+ return {"pending": len([row for row in items
201
+ if row.get("status") in ("pending", "overdue")]),
202
+ "due": len(due), "hint": str(hint or "")[:80]}
203
+
204
+
205
+ def add_commitment(store, text, due_ts, now=None, kind="his_event"):
206
+ data_dir = _data_dir(store)
207
+ path = (data_dir / PROSPECTIVE_FILE) if data_dir else None
208
+ if path is None:
209
+ return None
210
+ doc = _read(path, {"items": []})
211
+ items = [row for row in (doc.get("items") or []) if isinstance(row, dict)]
212
+ try:
213
+ record = _prospective.entry(kind, text, due_ts, now=now)
214
+ items, changed = _prospective.add(items, record)
215
+ except Exception:
216
+ return None
217
+ doc.update({"schema": SCHEMA, "items": items[-200:],
218
+ "updated_at": round(float(now or time.time()), 3)})
219
+ _write(path, doc)
220
+ return record if changed else None
221
+
222
+
223
+ def reconstruction_view(entries):
224
+ try:
225
+ result = _reconstruct.conserve(entries) or {}
226
+ except Exception:
227
+ return {"drift": 0.0}
228
+ return {"drift": round(float(result.get("total") or 0.0), 4),
229
+ "scale": result.get("scale", 1.0)}
230
+
231
+
232
+ def turn_memory_view(trace_rows, reply="", now=None):
233
+ try:
234
+ rows = _turn_memory.build(trace_rows)
235
+ record = _turn_memory.build_record(rows, reply=reply,
236
+ ts=float(now or time.time()))
237
+ receipt = _turn_memory.render_receipt(record)
238
+ except Exception:
239
+ rows, record, receipt = [], {}, ""
240
+ return {"rows": rows, "receipt": str(receipt or "")[:120],
241
+ "record": record}
242
+
243
+
244
+ def expression_block(store=None, state=None, relationship=None,
245
+ soft_target=0, soft_max=0, hour=None):
246
+ """Wording constraints from the expression P0 modules."""
247
+ core = (state or {}).get("core") if isinstance(state, dict) else {}
248
+ core = core if isinstance(core, dict) else {}
249
+ affect = {"valence": float(core.get("v") or 0.0),
250
+ "arousal": float(core.get("a") or 0.5),
251
+ "stress": float(core.get("stress") or 0.2)}
252
+ rel = relationship if isinstance(relationship, dict) else {}
253
+ out = {"rules": "", "template": "", "topic": "", "voice": "",
254
+ "template_name": "default", "topic_name": "", "voice_name": ""}
255
+ try:
256
+ out["rules"] = _prompt_rules.render_rules(
257
+ affect=affect, relation=rel, certainty=0.7,
258
+ soft_target=int(soft_target or 0), soft_max=int(soft_max or 0))
259
+ except Exception:
260
+ out["rules"] = ""
261
+ try:
262
+ built = _expr_template.build_reply({}, rel)
263
+ out["expr_template_order"] = [str(item) for item in
264
+ (built.get("order") or [])][:8]
265
+ except Exception:
266
+ out["expr_template_order"] = []
267
+ try:
268
+ rendered = _template.render(
269
+ "{{# if tense}}稳一点说。{{/ if}}{{closing}}",
270
+ {"tense": float(core.get("stress") or 0.0) >= 0.6,
271
+ "closing": "不用急着答。"})
272
+ out["template"] = str(rendered or "")
273
+ out["template_name"] = "reply-soft"
274
+ except Exception:
275
+ out["template"] = ""
276
+ try:
277
+ topic = _topic.topic_control(turns=[], state=affect, relationship=rel,
278
+ hour=hour)
279
+ out["topic"] = str((_topic.directives(topic) or {}).get("tone") or "")
280
+ out["topic_name"] = str((topic or {}).get("tendency")
281
+ or (topic or {}).get("holder") or "")
282
+ except Exception:
283
+ out["topic"] = ""
284
+ try:
285
+ data_dir = _data_dir(store)
286
+ bank = _read((data_dir / VOICE_FILE) if data_dir else None,
287
+ {"entries": []})
288
+ entries = [row for row in (bank.get("entries") or [])
289
+ if isinstance(row, dict)]
290
+ picked = _voice.pick({"v": affect["valence"], "tension": affect["stress"]},
291
+ rel, [], float(time.time()), entries=entries)
292
+ if picked:
293
+ out["voice"] = str(picked[0].get("phrase") or "")[:40]
294
+ out["voice_name"] = str(picked[0].get("id") or "voice")[:24]
295
+ except Exception:
296
+ out["voice"] = ""
297
+ return out
298
+
299
+
300
+ def record_extension_snapshot(store, snapshot):
301
+ data_dir = _data_dir(store)
302
+ path = (data_dir / EXTENSIONS_FILE) if data_dir else None
303
+ if path is None:
304
+ return False
305
+ doc = _read(path, {})
306
+ doc.update({"schema": SCHEMA, "ts": round(float(time.time()), 3),
307
+ "latest": snapshot,
308
+ "history": ((doc.get("history") or [])
309
+ + [snapshot])[-50:]})
310
+ return _write(path, doc)
311
+
312
+
313
+ def extension_summary(store):
314
+ data_dir = _data_dir(store)
315
+ doc = _read((data_dir / EXTENSIONS_FILE) if data_dir else None, {})
316
+ latest = doc.get("latest") if isinstance(doc.get("latest"), dict) else {}
317
+ return latest