pdm-memory 0.1.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.
@@ -0,0 +1,271 @@
1
+ """
2
+ Cloud Storage Driver — Task 2.1
3
+
4
+ Implements BaseStorage by making HTTP requests to the AZUS Companion API
5
+ instead of reading/writing a local file.
6
+
7
+ Requires: httpx (listed as a main dependency in pyproject.toml).
8
+
9
+ Usage:
10
+ from pdm_memory.storage.cloud_driver import CloudDriver
11
+ from pdm_memory.auth import JWTAuth
12
+
13
+ auth = JWTAuth(token="eyJ...")
14
+ driver = CloudDriver(auth=auth, base_url="https://api.azus.ai")
15
+
16
+ # Or via Memory:
17
+ mem = Memory(store="cloud", token="eyJ...", cloud_url="https://api.azus.ai")
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ from typing import List, Optional
25
+
26
+ from pdm_memory.auth.jwt_handler import JWTAuth
27
+ from pdm_memory.core.signature import DrawerInfo, SignatureRecord
28
+ from pdm_memory.storage.base import BaseStorage
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Default AZUS Companion API base URL
33
+ DEFAULT_CLOUD_URL = "https://api.azus.ai"
34
+
35
+
36
+ class CloudDriver(BaseStorage):
37
+ """
38
+ HTTP-backed PDM storage driver targeting the AZUS Companion API.
39
+
40
+ All network calls use httpx (sync). Timeout is configurable.
41
+ On 401 responses, automatically attempts token refresh if JWTAuth
42
+ was constructed with a refresh_token.
43
+
44
+ Args:
45
+ auth: JWTAuth instance carrying the user's access token.
46
+ base_url: Base URL of the AZUS Companion API.
47
+ timeout: Request timeout in seconds.
48
+ user: Username to scope all requests (overrides token claim).
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ auth: JWTAuth,
54
+ base_url: str = DEFAULT_CLOUD_URL,
55
+ timeout: float = 15.0,
56
+ user: str = "default",
57
+ ) -> None:
58
+ try:
59
+ import httpx # noqa: F401 — guard for optional dep
60
+ except ImportError:
61
+ raise ImportError(
62
+ "httpx is required for cloud mode. Install it: pip install httpx"
63
+ )
64
+ self._auth = auth
65
+ self._base_url = base_url.rstrip("/")
66
+ self._timeout = timeout
67
+ self._user = user
68
+
69
+ # ------------------------------------------------------------------
70
+ # BaseStorage
71
+ # ------------------------------------------------------------------
72
+
73
+ def save(self, sig: SignatureRecord) -> str:
74
+ """POST /api/v1/pdm/ingest — create a new signature in the cloud."""
75
+ payload = self._record_to_payload(sig)
76
+ resp = self._post("/api/v1/pdm/ingest", payload)
77
+ data = resp.json()
78
+ # Cloud may assign a different ID
79
+ cloud_id = data.get("id") or data.get("signature_id") or sig.id
80
+ logger.debug("[PDM-Cloud] Saved signature %s", cloud_id)
81
+ return str(cloud_id)
82
+
83
+ def get(self, memory_id: str, user: str = "default") -> Optional[SignatureRecord]:
84
+ """GET /api/v1/pdm/signatures/<id>/"""
85
+ try:
86
+ resp = self._get(f"/api/v1/pdm/signatures/{memory_id}/")
87
+ return self._payload_to_record(resp.json())
88
+ except Exception as e:
89
+ logger.warning("[PDM-Cloud] get(%s) failed: %s", memory_id, e)
90
+ return None
91
+
92
+ def update(self, memory_id: str, **fields) -> None:
93
+ """PATCH /api/v1/pdm/signatures/<id>/"""
94
+ try:
95
+ self._patch(f"/api/v1/pdm/signatures/{memory_id}/", fields)
96
+ except Exception as e:
97
+ logger.warning("[PDM-Cloud] update(%s) failed: %s", memory_id, e)
98
+
99
+ def delete(self, memory_id: str, user: str = "default") -> None:
100
+ """DELETE /api/v1/pdm/signatures/<id>/"""
101
+ try:
102
+ import httpx
103
+ self._auth.ensure_fresh()
104
+ resp = httpx.delete(
105
+ f"{self._base_url}/api/v1/pdm/signatures/{memory_id}/",
106
+ headers=self._auth.headers(),
107
+ timeout=self._timeout,
108
+ )
109
+ resp.raise_for_status()
110
+ except Exception as e:
111
+ logger.warning("[PDM-Cloud] delete(%s) failed: %s", memory_id, e)
112
+
113
+ def list(
114
+ self,
115
+ user: str = "default",
116
+ limit: int = 100,
117
+ min_pressure: float = 0.0,
118
+ drawer: Optional[str] = None,
119
+ ) -> List[SignatureRecord]:
120
+ """GET /api/v1/pdm/retrieve — list signatures with filters."""
121
+ params: dict = {"limit": limit, "min_pressure": min_pressure}
122
+ if drawer:
123
+ params["drawer"] = drawer
124
+ try:
125
+ resp = self._get("/api/v1/pdm/retrieve", params=params)
126
+ data = resp.json()
127
+ items = data if isinstance(data, list) else data.get("results", [])
128
+ return [self._payload_to_record(item) for item in items]
129
+ except Exception as e:
130
+ logger.warning("[PDM-Cloud] list() failed: %s", e)
131
+ return []
132
+
133
+ def list_drawers(self, user: str = "default") -> List[DrawerInfo]:
134
+ """GET /api/v1/pdm/drawers"""
135
+ try:
136
+ resp = self._get("/api/v1/pdm/drawers")
137
+ items = resp.json()
138
+ if not isinstance(items, list):
139
+ items = items.get("results", [])
140
+ return [
141
+ DrawerInfo(
142
+ domain=item.get("domain", ""),
143
+ signature_count=item.get("signature_count", 0),
144
+ avg_pressure=float(item.get("avg_pressure", 0.0)),
145
+ description=item.get("description", ""),
146
+ )
147
+ for item in items
148
+ ]
149
+ except Exception as e:
150
+ logger.warning("[PDM-Cloud] list_drawers() failed: %s", e)
151
+ return []
152
+
153
+ # ------------------------------------------------------------------
154
+ # HTTP helpers
155
+ # ------------------------------------------------------------------
156
+
157
+ def _post(self, path: str, payload: dict):
158
+ import httpx
159
+ self._auth.ensure_fresh()
160
+ resp = httpx.post(
161
+ f"{self._base_url}{path}",
162
+ json=payload,
163
+ headers=self._auth.headers(),
164
+ timeout=self._timeout,
165
+ )
166
+ self._handle_auth_retry(resp, "POST", path, payload)
167
+ resp.raise_for_status()
168
+ return resp
169
+
170
+ def _get(self, path: str, params: Optional[dict] = None):
171
+ import httpx
172
+ self._auth.ensure_fresh()
173
+ resp = httpx.get(
174
+ f"{self._base_url}{path}",
175
+ params=params,
176
+ headers=self._auth.headers(),
177
+ timeout=self._timeout,
178
+ )
179
+ self._handle_auth_retry(resp, "GET", path)
180
+ resp.raise_for_status()
181
+ return resp
182
+
183
+ def _patch(self, path: str, payload: dict):
184
+ import httpx
185
+ self._auth.ensure_fresh()
186
+ resp = httpx.patch(
187
+ f"{self._base_url}{path}",
188
+ json=payload,
189
+ headers=self._auth.headers(),
190
+ timeout=self._timeout,
191
+ )
192
+ resp.raise_for_status()
193
+ return resp
194
+
195
+ def _handle_auth_retry(self, resp, method: str, path: str, payload=None) -> None:
196
+ """On 401, attempt one token refresh and retry."""
197
+ import httpx
198
+ if resp.status_code == 401:
199
+ if self._auth.refresh():
200
+ if method == "POST":
201
+ resp2 = httpx.post(
202
+ f"{self._base_url}{path}",
203
+ json=payload,
204
+ headers=self._auth.headers(),
205
+ timeout=self._timeout,
206
+ )
207
+ resp.__dict__.update(resp2.__dict__)
208
+ elif method == "GET":
209
+ resp2 = httpx.get(
210
+ f"{self._base_url}{path}",
211
+ headers=self._auth.headers(),
212
+ timeout=self._timeout,
213
+ )
214
+ resp.__dict__.update(resp2.__dict__)
215
+
216
+ # ------------------------------------------------------------------
217
+ # Serialisation helpers
218
+ # ------------------------------------------------------------------
219
+
220
+ @staticmethod
221
+ def _record_to_payload(sig: SignatureRecord) -> dict:
222
+ return {
223
+ "compressed_fact": sig.compressed_fact,
224
+ "source": sig.source,
225
+ "p_magnitude": sig.p_magnitude,
226
+ "t_persistence": sig.t_persistence,
227
+ "phase_privilege": sig.phase_privilege,
228
+ "intent_tags": sig.intent_tags,
229
+ "question_regime": sig.question_regime,
230
+ "drawer_domain": sig.drawer_domain,
231
+ "metadata": sig.metadata,
232
+ }
233
+
234
+ @staticmethod
235
+ def _payload_to_record(data: dict) -> SignatureRecord:
236
+ from datetime import datetime
237
+ tags = data.get("intent_tags") or []
238
+ if isinstance(tags, str):
239
+ try:
240
+ tags = json.loads(tags)
241
+ except Exception:
242
+ tags = []
243
+
244
+ def _dt(val):
245
+ if not val:
246
+ return None
247
+ try:
248
+ return datetime.fromisoformat(str(val))
249
+ except Exception:
250
+ return None
251
+
252
+ return SignatureRecord(
253
+ id=str(data.get("id", "")),
254
+ user=str(data.get("user", "default")),
255
+ compressed_fact=data.get("compressed_fact", ""),
256
+ source=data.get("source", "chat"),
257
+ p_magnitude=float(data.get("p_magnitude", 50.0)),
258
+ t_persistence=float(data.get("t_persistence", 30.0)),
259
+ phase_privilege=float(data.get("phase_privilege", 1.0)),
260
+ effective_spike=data.get("effective_spike"),
261
+ intent_tags=tags,
262
+ question_regime=data.get("question_regime", "neutral"),
263
+ domain=data.get("domain", "insight"),
264
+ drawer_domain=data.get("drawer_domain") or data.get("drawer", "general"),
265
+ retrieval_count=int(data.get("retrieval_count", 0)),
266
+ last_retrieved=_dt(data.get("last_retrieved")),
267
+ created_at=_dt(data.get("created_at")),
268
+ validation_prediction_total=int(data.get("validation_prediction_total", 0)),
269
+ validation_prediction_correct=int(data.get("validation_prediction_correct", 0)),
270
+ metadata=data.get("metadata") or {},
271
+ )
@@ -0,0 +1,331 @@
1
+ """
2
+ SQLite Storage Driver — Task 1.2
3
+
4
+ Zero external dependencies beyond Python stdlib.
5
+ Stores all PDM signatures in a single local .db file.
6
+
7
+ Privacy guarantee: raw compressed_fact is stored by default.
8
+ If store_raw=False, only the SHA-256 hash of the text is stored
9
+ (the memory is opaque even to someone with file access).
10
+
11
+ Schema is auto-created on first run.
12
+
13
+ Usage:
14
+ driver = SQLiteDriver("./my_app.db")
15
+ memory_id = driver.save(sig)
16
+ hits = driver.list(user="alice", min_pressure=50)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import logging
24
+ import sqlite3
25
+ import threading
26
+ from datetime import datetime, timezone
27
+ from typing import List, Optional
28
+
29
+ from pdm_memory.core.signature import DrawerInfo, SignatureRecord
30
+ from pdm_memory.storage.base import BaseStorage
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _SCHEMA = """
35
+ CREATE TABLE IF NOT EXISTS pdm_signatures (
36
+ id TEXT PRIMARY KEY,
37
+ user TEXT NOT NULL DEFAULT 'default',
38
+ compressed_fact TEXT NOT NULL,
39
+ compressed_fact_hash TEXT NOT NULL,
40
+ source TEXT NOT NULL DEFAULT 'chat',
41
+ p_magnitude REAL NOT NULL DEFAULT 50.0,
42
+ t_persistence REAL NOT NULL DEFAULT 30.0,
43
+ phase_privilege REAL NOT NULL DEFAULT 1.0,
44
+ effective_spike REAL,
45
+ intent_tags TEXT NOT NULL DEFAULT '[]',
46
+ question_regime TEXT NOT NULL DEFAULT 'neutral',
47
+ domain TEXT NOT NULL DEFAULT 'insight',
48
+ drawer_domain TEXT NOT NULL DEFAULT 'general',
49
+ retrieval_count INTEGER NOT NULL DEFAULT 0,
50
+ last_retrieved TEXT,
51
+ created_at TEXT NOT NULL,
52
+ validation_prediction_total INTEGER NOT NULL DEFAULT 0,
53
+ validation_prediction_correct INTEGER NOT NULL DEFAULT 0,
54
+ decay_rate REAL NOT NULL DEFAULT 0.9,
55
+ t_deadline TEXT,
56
+ urgency_rate REAL NOT NULL DEFAULT 2.0,
57
+ metadata TEXT NOT NULL DEFAULT '{}'
58
+ );
59
+
60
+ CREATE INDEX IF NOT EXISTS idx_pdm_user_pressure
61
+ ON pdm_signatures (user, p_magnitude DESC);
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_pdm_user_drawer
64
+ ON pdm_signatures (user, drawer_domain);
65
+
66
+ CREATE TABLE IF NOT EXISTS pdm_drawers (
67
+ domain TEXT NOT NULL,
68
+ user TEXT NOT NULL DEFAULT 'default',
69
+ description TEXT NOT NULL DEFAULT '',
70
+ PRIMARY KEY (domain, user)
71
+ );
72
+ """
73
+
74
+
75
+ def _now_iso() -> str:
76
+ return datetime.now(tz=timezone.utc).isoformat()
77
+
78
+
79
+ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
80
+ if not s:
81
+ return None
82
+ try:
83
+ return datetime.fromisoformat(s)
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ class SQLiteDriver(BaseStorage):
89
+ """
90
+ SQLite-backed PDM storage. Thread-safe (one connection per thread).
91
+
92
+ Args:
93
+ db_path: Path to the .db file. Created automatically.
94
+ store_raw: If False, only the SHA-256 hash of compressed_fact is
95
+ stored — the text itself never touches disk.
96
+ """
97
+
98
+ def __init__(self, db_path: str = "./pdm_memory.db", store_raw: bool = True) -> None:
99
+ self.db_path = db_path
100
+ self.store_raw = store_raw
101
+ self._local = threading.local()
102
+ # Initialise schema on startup
103
+ conn = self._conn()
104
+ conn.execute("PRAGMA journal_mode=WAL")
105
+ conn.executescript(_SCHEMA)
106
+ conn.commit()
107
+ logger.debug("[PDM-SQLite] Opened %s (store_raw=%s)", db_path, store_raw)
108
+
109
+ # ------------------------------------------------------------------
110
+ # Connection management (one connection per thread)
111
+ # ------------------------------------------------------------------
112
+
113
+ def _conn(self) -> sqlite3.Connection:
114
+ if not hasattr(self._local, "conn") or self._local.conn is None:
115
+ self._local.conn = sqlite3.connect(
116
+ self.db_path,
117
+ detect_types=sqlite3.PARSE_DECLTYPES,
118
+ check_same_thread=False,
119
+ )
120
+ self._local.conn.row_factory = sqlite3.Row
121
+ # Optimise connection performance pragmas
122
+ self._local.conn.execute("PRAGMA synchronous=NORMAL")
123
+ self._local.conn.execute("PRAGMA temp_store=MEMORY")
124
+ return self._local.conn
125
+
126
+ def close(self) -> None:
127
+ if hasattr(self._local, "conn") and self._local.conn:
128
+ self._local.conn.close()
129
+ self._local.conn = None
130
+
131
+ # ------------------------------------------------------------------
132
+ # BaseStorage implementation
133
+ # ------------------------------------------------------------------
134
+
135
+ def save(self, sig: SignatureRecord) -> str:
136
+ """Insert a new signature. Returns sig.id."""
137
+ text = sig.compressed_fact or ""
138
+ text_hash = hashlib.sha256(text.encode()).hexdigest()
139
+ stored_text = text if self.store_raw else f"[HASH:{text_hash}]"
140
+
141
+ conn = self._conn()
142
+ conn.execute(
143
+ """
144
+ INSERT OR REPLACE INTO pdm_signatures (
145
+ id, user, compressed_fact, compressed_fact_hash, source,
146
+ p_magnitude, t_persistence, phase_privilege, effective_spike,
147
+ intent_tags, question_regime, domain, drawer_domain,
148
+ retrieval_count, last_retrieved, created_at,
149
+ validation_prediction_total, validation_prediction_correct,
150
+ decay_rate, t_deadline, urgency_rate, metadata
151
+ ) VALUES (
152
+ ?, ?, ?, ?, ?,
153
+ ?, ?, ?, ?,
154
+ ?, ?, ?, ?,
155
+ ?, ?, ?,
156
+ ?, ?,
157
+ ?, ?, ?, ?
158
+ )
159
+ """,
160
+ (
161
+ sig.id,
162
+ sig.user,
163
+ stored_text,
164
+ text_hash,
165
+ sig.source,
166
+ sig.p_magnitude,
167
+ sig.t_persistence,
168
+ sig.phase_privilege,
169
+ sig.effective_spike,
170
+ json.dumps(sig.intent_tags),
171
+ sig.question_regime,
172
+ sig.domain,
173
+ sig.drawer_domain,
174
+ sig.retrieval_count,
175
+ sig.last_retrieved.isoformat() if sig.last_retrieved else None,
176
+ (sig.created_at or datetime.now(tz=timezone.utc)).isoformat(),
177
+ sig.validation_prediction_total,
178
+ sig.validation_prediction_correct,
179
+ sig.decay_rate,
180
+ sig.t_deadline.isoformat() if sig.t_deadline else None,
181
+ sig.urgency_rate,
182
+ json.dumps(sig.metadata),
183
+ ),
184
+ )
185
+ # Upsert drawer record
186
+ conn.execute(
187
+ "INSERT OR IGNORE INTO pdm_drawers (domain, user, description) VALUES (?, ?, ?)",
188
+ (sig.drawer_domain, sig.user, ""),
189
+ )
190
+ conn.commit()
191
+ logger.debug("[PDM-SQLite] Saved signature %s (P=%.1f)", sig.id, sig.p_magnitude)
192
+ return sig.id
193
+
194
+ def get(self, memory_id: str, user: str = "default") -> Optional[SignatureRecord]:
195
+ row = self._conn().execute(
196
+ "SELECT * FROM pdm_signatures WHERE id = ? AND user = ?",
197
+ (memory_id, user),
198
+ ).fetchone()
199
+ return self._row_to_record(row) if row else None
200
+
201
+ def update(self, memory_id: str, **fields) -> None:
202
+ if not fields:
203
+ return
204
+ # Serialise JSON fields
205
+ if "intent_tags" in fields:
206
+ fields["intent_tags"] = json.dumps(fields["intent_tags"])
207
+ if "metadata" in fields:
208
+ fields["metadata"] = json.dumps(fields["metadata"])
209
+ if "last_retrieved" in fields and isinstance(fields["last_retrieved"], datetime):
210
+ fields["last_retrieved"] = fields["last_retrieved"].isoformat()
211
+
212
+ set_clause = ", ".join(f"{k} = ?" for k in fields)
213
+ values = list(fields.values()) + [memory_id]
214
+ self._conn().execute(
215
+ f"UPDATE pdm_signatures SET {set_clause} WHERE id = ?",
216
+ values,
217
+ )
218
+ self._conn().commit()
219
+
220
+ def update_batch(self, updates: List[tuple[str, dict]]) -> None:
221
+ if not updates:
222
+ return
223
+ conn = self._conn()
224
+ for memory_id, fields in updates:
225
+ if not fields:
226
+ continue
227
+ fields_copy = dict(fields)
228
+ if "intent_tags" in fields_copy:
229
+ fields_copy["intent_tags"] = json.dumps(fields_copy["intent_tags"])
230
+ if "metadata" in fields_copy:
231
+ fields_copy["metadata"] = json.dumps(fields_copy["metadata"])
232
+ if "last_retrieved" in fields_copy and isinstance(fields_copy["last_retrieved"], datetime):
233
+ fields_copy["last_retrieved"] = fields_copy["last_retrieved"].isoformat()
234
+
235
+ set_clause = ", ".join(f"{k} = ?" for k in fields_copy)
236
+ values = list(fields_copy.values()) + [memory_id]
237
+ conn.execute(
238
+ f"UPDATE pdm_signatures SET {set_clause} WHERE id = ?",
239
+ values,
240
+ )
241
+ conn.commit()
242
+
243
+ def delete(self, memory_id: str, user: str = "default") -> None:
244
+ self._conn().execute(
245
+ "DELETE FROM pdm_signatures WHERE id = ? AND user = ?",
246
+ (memory_id, user),
247
+ )
248
+ self._conn().commit()
249
+
250
+ def list(
251
+ self,
252
+ user: str = "default",
253
+ limit: int = 100,
254
+ min_pressure: float = 0.0,
255
+ drawer: Optional[str] = None,
256
+ ) -> List[SignatureRecord]:
257
+ query = (
258
+ "SELECT * FROM pdm_signatures WHERE user = ? AND p_magnitude >= ?"
259
+ )
260
+ params: list = [user, min_pressure]
261
+ if drawer:
262
+ query += " AND drawer_domain = ?"
263
+ params.append(drawer)
264
+ query += " ORDER BY p_magnitude DESC LIMIT ?"
265
+ params.append(limit)
266
+
267
+ rows = self._conn().execute(query, params).fetchall()
268
+ return [self._row_to_record(r) for r in rows]
269
+
270
+ def list_drawers(self, user: str = "default") -> List[DrawerInfo]:
271
+ rows = self._conn().execute(
272
+ """
273
+ SELECT
274
+ d.domain,
275
+ d.description,
276
+ COUNT(s.id) AS sig_count,
277
+ AVG(s.p_magnitude) AS avg_pressure
278
+ FROM pdm_drawers d
279
+ LEFT JOIN pdm_signatures s
280
+ ON s.drawer_domain = d.domain AND s.user = d.user
281
+ WHERE d.user = ?
282
+ GROUP BY d.domain, d.description
283
+ ORDER BY d.domain
284
+ """,
285
+ (user,),
286
+ ).fetchall()
287
+ return [
288
+ DrawerInfo(
289
+ domain=r["domain"],
290
+ signature_count=r["sig_count"] or 0,
291
+ avg_pressure=round(r["avg_pressure"] or 0.0, 2),
292
+ description=r["description"] or "",
293
+ )
294
+ for r in rows
295
+ ]
296
+
297
+ def count(self, user: str = "default") -> int:
298
+ row = self._conn().execute(
299
+ "SELECT COUNT(*) FROM pdm_signatures WHERE user = ?", (user,)
300
+ ).fetchone()
301
+ return row[0] if row else 0
302
+
303
+ # ------------------------------------------------------------------
304
+ # Private helpers
305
+ # ------------------------------------------------------------------
306
+
307
+ @staticmethod
308
+ def _row_to_record(row: sqlite3.Row) -> SignatureRecord:
309
+ return SignatureRecord(
310
+ id=row["id"],
311
+ user=row["user"],
312
+ compressed_fact=row["compressed_fact"],
313
+ source=row["source"],
314
+ p_magnitude=row["p_magnitude"],
315
+ t_persistence=row["t_persistence"],
316
+ phase_privilege=row["phase_privilege"],
317
+ effective_spike=row["effective_spike"],
318
+ intent_tags=json.loads(row["intent_tags"] or "[]"),
319
+ question_regime=row["question_regime"],
320
+ domain=row["domain"],
321
+ drawer_domain=row["drawer_domain"],
322
+ retrieval_count=row["retrieval_count"],
323
+ last_retrieved=_parse_dt(row["last_retrieved"]),
324
+ created_at=_parse_dt(row["created_at"]),
325
+ validation_prediction_total=row["validation_prediction_total"],
326
+ validation_prediction_correct=row["validation_prediction_correct"],
327
+ decay_rate=row["decay_rate"],
328
+ t_deadline=_parse_dt(row["t_deadline"]),
329
+ urgency_rate=row["urgency_rate"],
330
+ metadata=json.loads(row["metadata"] or "{}"),
331
+ )