mnemonic-proxy 0.3.2__py3-none-any.whl

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.
ctxproxy/autosave.py ADDED
@@ -0,0 +1,350 @@
1
+ # Author: Maurizio Verde — LastReload
2
+ """Salvataggio / ripristino automatico del segmento attivo.
3
+
4
+ Strata tiene UNA conversazione (più pochi checkpoint) e la perde al riavvio o quando un'altra conversazione la
5
+ sostituisce. Rileggere 70–100K token costa 30–40 s; ripristinare un file di sessione ~1–2 s.
6
+
7
+ - `Tracker` avvolge l'Upstream e sa che cosa Strata ha in memoria: dopo ogni chiamata registra l'impronta del prompt
8
+ (testo del template SENZA il prompt di generazione: è esattamente il checkpoint di fine turno che Strata riusa) e
9
+ il contatore `requests` di /v1/status. Prima di ogni chiamata: se Strata è ripartito (started diverso, o non
10
+ caricato), se qualcun altro l'ha usato (requests diverso) o se tiene un'altra conversazione, e c'è un autosalvataggio
11
+ il cui testo è prefisso ESATTO del nuovo prompt e vale almeno `autorestore_min_gain` token in più, fa `restore`.
12
+ - `AutoSaver` (thread): quando la conversazione che occupa Strata è ferma da `autosave_idle_s`, nessuna richiesta è
13
+ in corso (lock del proxy libero, /v1/status senza in_flight) e lo stato è ancora il nostro, `save` in un file
14
+ `<conv>-seg<N>-auto-<impronta>.bin`; registra file/token/byte/impronta nella tabella `autosaves`. Pulizia: un file
15
+ il cui testo è prefisso esatto di quello appena salvato è superato e si cancella; poi al massimo `autosave_keep`
16
+ file e `autosave_max_gb`. Testo e archivio SQLite restano sempre.
17
+ - Lo stato del Tracker è salvato nella tabella `kv`: dopo un riavvio del PROXY con Strata ancora vivo e intatto
18
+ (stesso started/requests) non serve alcun restore.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shutil
25
+ import threading
26
+ import time
27
+
28
+ from .core import H
29
+ from .render import render, template_kwargs
30
+ from .upstream import UpstreamError
31
+
32
+ STATE_KEY = "strata_state"
33
+
34
+
35
+ def prompt_text(body: dict) -> str | None:
36
+ """Testo del prompt fino all'ultimo confine di turno (dove Strata mette il checkpoint riusabile)."""
37
+ try:
38
+ return render(body.get("messages") or [], body.get("tools") or None, template_kwargs(body),
39
+ add_generation_prompt=False)
40
+ except (ValueError, TypeError, KeyError, AttributeError):
41
+ return None
42
+
43
+
44
+ class _TrackedStream:
45
+ def __init__(self, call, on_done, on_abort):
46
+ self.call, self.on_done, self.on_abort = call, on_done, on_abort
47
+ self.finished = False
48
+
49
+ def __iter__(self):
50
+ usage = None
51
+ for ch in self.call:
52
+ if ch.get("usage"):
53
+ usage = ch["usage"]
54
+ yield ch
55
+ self.finished = True
56
+ self.on_done(usage)
57
+
58
+ def close(self):
59
+ self.call.close()
60
+ if not self.finished:
61
+ self.finished = True
62
+ self.on_abort()
63
+
64
+
65
+ class Tracker:
66
+ """Upstream con memoria di ciò che Strata contiene. Stessa interfaccia di Upstream (chat, chat_stream, slot, raw)."""
67
+
68
+ def __init__(self, up, store, journal, cfg):
69
+ self.up, self.store, self.journal, self.cfg = up, store, journal, cfg
70
+ self.ctx: tuple[str | None, int] = (None, 0) # (conv, seg) della richiesta in corso (lo imposta il proxy)
71
+ self.saved: dict[str, dict] = {} # file salvati da questo processo -> stato
72
+ self.skip_restore = False
73
+ self.text = None # testo dell'ultimo prompt letto da Strata (questo processo)
74
+ self.mu = threading.RLock()
75
+ self.state = self._load()
76
+
77
+ # ---------- passthrough ----------
78
+ def __getattr__(self, k):
79
+ return getattr(self.up, k)
80
+
81
+ def raw(self, *a, **kw):
82
+ return self.up.raw(*a, **kw)
83
+
84
+ # ---------- stato persistente ----------
85
+ def _load(self):
86
+ try:
87
+ v = self.store.kv_get(STATE_KEY)
88
+ return json.loads(v) if v else None
89
+ except (ValueError, TypeError):
90
+ return None
91
+
92
+ def _set(self, st):
93
+ with self.mu:
94
+ self.state = st
95
+ try:
96
+ self.store.kv_set(STATE_KEY, json.dumps(st) if st else "")
97
+ except Exception: # noqa: BLE001
98
+ pass
99
+
100
+ def status(self) -> dict | None:
101
+ """Stato del motore nella forma di /v1/status di Strata (llama-server: ricavato da /slots)."""
102
+ from .engines import normalize_status
103
+ try:
104
+ return normalize_status(getattr(self.up, "engine", None), self.up)
105
+ except Exception: # noqa: BLE001
106
+ return None
107
+
108
+ def check(self, s: dict | None = None, where: str = "") -> dict | None:
109
+ """Lo stato registrato vale ancora? Altrimenti lo azzera (e lo annota). -> stato valido o None."""
110
+ st = self.state
111
+ if st is None:
112
+ return None
113
+ s = s if s is not None else self.status()
114
+ why = None
115
+ if s is None:
116
+ why = "status_unavailable"
117
+ elif not s.get("loaded", True):
118
+ why = "unloaded"
119
+ elif st.get("started") is not None and s.get("started") != st.get("started"):
120
+ why = "strata_restart"
121
+ elif st.get("requests") is not None and \
122
+ (s.get("activity") or {}).get("requests") not in (st["requests"], st["requests"] - 1):
123
+ # -1: il totale di Strata può aggiornarsi un attimo dopo la fine della risposta
124
+ why = "foreign_request"
125
+ if why:
126
+ self.journal.log("strata_state_lost", reason=why, conv=st.get("conv"), seg=st.get("seg"),
127
+ tokens=st.get("tokens"), where=where)
128
+ self._set(None)
129
+ return None
130
+ return st
131
+
132
+ # ---------- chiamate ----------
133
+ def _before(self, body: dict):
134
+ """-> (testo del prompt, stato /v1/status prima della chiamata). Eventuale restore automatico."""
135
+ text = prompt_text(body)
136
+ s = self.status()
137
+ st = self.check(s, "before_call")
138
+ if self.skip_restore:
139
+ self.skip_restore = False
140
+ elif text is not None and self.cfg.autorestore:
141
+ self._maybe_restore(text, st, s)
142
+ s = self.status()
143
+ return text, s
144
+
145
+ def _after(self, text, s, usage, ok=True):
146
+ if not ok or text is None or s is None:
147
+ self._set(None)
148
+ return
149
+ conv, seg = self.ctx
150
+ self.text = text
151
+ req = ((s.get("activity") or {}).get("requests") or 0) + 1
152
+ eng = getattr(self.up, "engine", None)
153
+ if eng is not None and eng.counter_after_call:
154
+ # llama-server: il contatore è l'id dell'ultimo compito dello slot (non cresce di 1): lo si rilegge
155
+ s2 = self.status()
156
+ req = (s2.get("activity") or {}).get("requests") if s2 else None
157
+ self._set({"phash": H(text), "plen": len(text), "tokens": int((usage or {}).get("prompt_tokens") or 0),
158
+ "conv": conv, "seg": seg, "started": s.get("started"),
159
+ "requests": req, "t_end": time.time()})
160
+
161
+ def chat(self, body: dict) -> dict:
162
+ text, s = self._before(body)
163
+ try:
164
+ r = self.up.chat(body)
165
+ except Exception:
166
+ self._set(None)
167
+ raise
168
+ self._after(text, s, r.get("usage"))
169
+ return r
170
+
171
+ def chat_stream(self, body: dict):
172
+ text, s = self._before(body)
173
+ try:
174
+ call = self.up.chat_stream(body)
175
+ except Exception:
176
+ self._set(None)
177
+ raise
178
+ return _TrackedStream(call, lambda u: self._after(text, s, u), lambda: self._set(None))
179
+
180
+ def slot(self, action: str, filename: str) -> dict:
181
+ r = self.up.slot(action, filename)
182
+ if action == "save" and self.state:
183
+ self.saved[filename] = dict(self.state)
184
+ elif action == "restore":
185
+ known = self.saved.get(filename) or self.store.autosave_state(filename)
186
+ s = self.status()
187
+ if known and s:
188
+ self._set({**known, "started": s.get("started"),
189
+ "requests": (s.get("activity") or {}).get("requests"), "t_end": time.time()})
190
+ else:
191
+ self._set(None)
192
+ self.skip_restore = True # restore deciso dal Manager (ancora): non sovrascriverlo
193
+ return r
194
+
195
+ # ---------- ripristino automatico ----------
196
+ def _maybe_restore(self, text: str, st: dict | None, s: dict | None):
197
+ if s is None:
198
+ return
199
+ have = 0
200
+ if st and st["plen"] <= len(text) and H(text[:st["plen"]]) == st["phash"]:
201
+ have = st["tokens"]
202
+ best = None
203
+ for row in self.store.autosaves(active_only=True):
204
+ if row["plen"] <= len(text) and (best is None or row["tokens"] > best["tokens"]) \
205
+ and H(text[:row["plen"]]) == row["phash"]:
206
+ best = row
207
+ if best is None or best["tokens"] - have < self.cfg.autorestore_min_gain:
208
+ return
209
+ t0 = time.time()
210
+ reason = "strata_restart" if st is None else "other_state"
211
+ if not s.get("loaded", True):
212
+ # motore spento (scaricato o morto): il restore vuole il modello caricato, lo si carica ora
213
+ try:
214
+ self.up.raw("POST", "/load", b"{}")
215
+ except Exception: # noqa: BLE001
216
+ pass
217
+ try:
218
+ r = self.up.slot("restore", best["file"])
219
+ except UpstreamError as e:
220
+ self.journal.log("autorestore_error", conv=best["conv"], file=best["file"], status=e.status,
221
+ error=str(e)[:300])
222
+ if e.status == 404:
223
+ self.store.autosave_deleted(best["file"])
224
+ self._set(None)
225
+ return
226
+ ms = round((time.time() - t0) * 1000)
227
+ self.store.autosave_used(best["file"])
228
+ s2 = self.status() or {}
229
+ self._set({**{k: best[k] for k in ("phash", "plen", "tokens", "conv", "seg")},
230
+ "started": s2.get("started"), "requests": (s2.get("activity") or {}).get("requests"),
231
+ "t_end": time.time()})
232
+ self.journal.log("autorestore", conv=best["conv"], seg=best["seg"], file=best["file"], tokens=best["tokens"],
233
+ had_tokens=have, gain_tokens=best["tokens"] - have, reason=reason, ms=ms,
234
+ restore_ms=(r.get("timings") or {}).get("restore_ms"), n_restored=r.get("n_restored"))
235
+
236
+
237
+ class AutoSaver:
238
+ def __init__(self, proxy):
239
+ self.proxy = proxy
240
+ self.cfg, self.store, self.journal = proxy.cfg, proxy.store, proxy.journal
241
+ self.tr: Tracker = proxy.up
242
+ self.stop_ev = threading.Event()
243
+ self.th = None
244
+ self._skip_logged = None
245
+
246
+ def start(self):
247
+ self.th = threading.Thread(target=self._loop, daemon=True, name="autosave")
248
+ self.th.start()
249
+ return self
250
+
251
+ def stop(self):
252
+ self.stop_ev.set()
253
+
254
+ def _loop(self):
255
+ while not self.stop_ev.wait(self.cfg.autosave_poll_s):
256
+ try:
257
+ self.tick()
258
+ except Exception as e: # noqa: BLE001
259
+ self.journal.log("autosave_error", step="tick", error=repr(e)[:300])
260
+
261
+ def _skip(self, reason, **kw):
262
+ key = (reason, (self.tr.state or {}).get("phash"))
263
+ if key != self._skip_logged:
264
+ self._skip_logged = key
265
+ self.journal.log("autosave_skip", reason=reason, **kw)
266
+ return None
267
+
268
+ def tick(self, now: float | None = None):
269
+ """Un controllo; -> evento autosave o None."""
270
+ cfg, tr = self.cfg, self.tr
271
+ now = now or time.time()
272
+ st = tr.state
273
+ if not st or not st.get("conv"):
274
+ return None
275
+ if now - st.get("t_end", now) < cfg.autosave_idle_s:
276
+ return None
277
+ if st.get("tokens", 0) < cfg.autosave_min_tokens:
278
+ return None
279
+ if self.store.autosave_with_phash(st["phash"]):
280
+ return None # già salvato
281
+ if not self.proxy.lock.acquire(blocking=False):
282
+ return None # una richiesta è in corso: mai salvare adesso
283
+ try:
284
+ s = tr.status()
285
+ if s is None or (s.get("activity") or {}).get("in_flight"):
286
+ return None
287
+ st = tr.check(s, "autosave")
288
+ if st is None:
289
+ return None
290
+ if cfg.slot_dir:
291
+ free = shutil.disk_usage(cfg.slot_dir).free
292
+ need = cfg.autosave_min_free_gb * 2 ** 30 + st["tokens"] * 20000
293
+ if free < need:
294
+ return self._skip("disk_low", free_gb=round(free / 2 ** 30, 1))
295
+ fn = "%s-seg%d-auto-%s.bin" % (st["conv"], st.get("seg") or 0, st["phash"][:10])
296
+ t0 = time.time()
297
+ try:
298
+ r = tr.up.slot("save", fn)
299
+ except Exception as e: # noqa: BLE001
300
+ self.journal.log("autosave_error", conv=st["conv"], file=fn, error=str(e)[:300])
301
+ return None
302
+ ms = round((time.time() - t0) * 1000)
303
+ nbytes = int(r.get("n_written") or 0)
304
+ self.store.add_autosave(fn, st["conv"], st.get("seg") or 0, st["phash"], st["plen"], st["tokens"],
305
+ nbytes, int(r.get("n_saved") or 0))
306
+ ev = self.journal.log("autosave", conv=st["conv"], seg=st.get("seg") or 0, file=fn, tokens=st["tokens"],
307
+ n_saved=r.get("n_saved"), bytes=nbytes, ms=ms, idle_s=round(now - st["t_end"]),
308
+ phash=st["phash"][:16])
309
+ # lo stato di Strata non cambia con un save (requests non conta i save): resta valido
310
+ self.prune(keep_file=fn, text=getattr(tr, "text", None) if H(getattr(tr, "text", "") or "") == st["phash"]
311
+ else None)
312
+ return ev
313
+ finally:
314
+ self.proxy.lock.release()
315
+
316
+ def prune(self, keep_file: str | None = None, text: str | None = None):
317
+ """Superato = un salvataggio più vecchio il cui testo è prefisso esatto di `text` (quello appena salvato):
318
+ la stessa conversazione più avanti lo rende inutile. Poi tetto di numero e di disco (più recenti tenuti)."""
319
+ cfg = self.cfg
320
+ rows = self.store.autosaves(active_only=True) # più recenti prima
321
+ drop, kept, total = [], [], 0
322
+ for r in rows:
323
+ if r["file"] != keep_file and text is not None and r["plen"] <= len(text) \
324
+ and H(text[:r["plen"]]) == r["phash"]:
325
+ drop.append((r, "superseded"))
326
+ continue
327
+ if r["file"] != keep_file and (len(kept) >= cfg.autosave_keep or
328
+ total + r["bytes"] > cfg.autosave_max_gb * 2 ** 30):
329
+ drop.append((r, "limit"))
330
+ continue
331
+ kept.append(r)
332
+ total += r["bytes"]
333
+ for r, why in drop:
334
+ ok = True
335
+ if cfg.slot_dir:
336
+ try:
337
+ os.remove(os.path.join(cfg.slot_dir, r["file"]))
338
+ except FileNotFoundError:
339
+ pass
340
+ except OSError as e:
341
+ ok = False
342
+ self.journal.log("autosave_error", step="prune", file=r["file"], error=str(e)[:200])
343
+ if ok:
344
+ if cfg.kv_archive:
345
+ from .kvarchive import discard
346
+ discard(cfg, r["file"], self.journal)
347
+ self.store.autosave_deleted(r["file"])
348
+ self.journal.log("autosave_prune", conv=r["conv"], seg=r["seg"], file=r["file"], reason=why,
349
+ bytes=r["bytes"])
350
+ return drop