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.
@@ -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()
@@ -2,6 +2,9 @@
2
2
  """BioCore runtime package: host-neutral policy shared by every adapter."""
3
3
  from __future__ import annotations
4
4
 
5
+ __version__ = "0.1.0"
6
+ VERSION = (0, 1, 0)
7
+
5
8
  from . import pipeline
6
9
 
7
10
  __all__ = ["pipeline"]
@@ -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"))}