minima-cli 0.4.9__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.
Files changed (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. minima_harness/tui/widgets/status.py +57 -0
@@ -0,0 +1,505 @@
1
+ """Per-recommendation decision log: the substrate for savings, calibration, and OPE.
2
+
3
+ Every recommendation is logged with its full candidate set, selection-propensity vector,
4
+ threshold, and counterfactual cost baselines; feedback reconciles the row with realized
5
+ outcome/cost/quality. /v1/savings, /v1/calibration, feedback-coverage, and offline policy
6
+ evaluation all read from here. Unlike the recstore (operational, TTL-bound), this log is
7
+ analytical and long-retention.
8
+
9
+ Backends mirror the recstore pattern: in-process default, SQLite for durability, an
10
+ org-scoped wrapper enforcing tenant isolation on every read.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sqlite3
17
+ import time
18
+ from dataclasses import asdict, dataclass, field
19
+ from threading import Lock
20
+ from typing import Protocol, runtime_checkable
21
+
22
+ from minima.config import Settings
23
+ from minima.logging import get_logger
24
+
25
+ log = get_logger("minima.decisionlog")
26
+
27
+ _SECONDS_PER_DAY = 86_400.0
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class CandidateSnapshot:
32
+ """One scored candidate at decision time, with its selection propensity."""
33
+
34
+ model_id: str
35
+ predicted_success: float
36
+ confidence: float
37
+ est_cost_usd: float
38
+ propensity: float
39
+ # Pre-calibration, pre-bonus Beta-posterior mean for the chosen candidate. Calibration
40
+ # is fit on THIS (not the deployed predicted_success) so the loop converges. Defaults
41
+ # to None for rows written before calibration existed (back-compat on deserialize).
42
+ raw_predicted_success: float | None = None
43
+ # Data-grounded predictable cost band at decision time; powers the cost-accuracy metric
44
+ # (within-band coverage). None for rows written before bands existed, or thin evidence.
45
+ est_cost_low: float | None = None
46
+ est_cost_high: float | None = None
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class DecisionRecord:
51
+ recommendation_id: str
52
+ org_id: str
53
+ lane: str
54
+ cluster: str
55
+ task_type: str
56
+ difficulty: str
57
+ fingerprint: str
58
+ ts: float # wall-clock epoch seconds
59
+ tau: float
60
+ policy: str # "argmin" | "epsilon_softmax"
61
+ epsilon: float
62
+ chosen_model_id: str
63
+ escalated: bool
64
+ # What the (advisory) shadow bandit would have picked; None when shadow is off. Logged
65
+ # for offline agreement/regret comparison — never affects the deployed decision.
66
+ shadow_chosen_model_id: str | None = None
67
+ # True when the epsilon branch actually changed the pick away from the argmin
68
+ # (distinct from policy == "epsilon_softmax", which only says exploration was POSSIBLE).
69
+ explored: bool = False
70
+ escalation_reasons: list[str] = field(default_factory=list)
71
+ candidates: list[CandidateSnapshot] = field(default_factory=list)
72
+ # Counterfactual baselines (same cost basis as the candidates, chosen once per set)
73
+ est_cost_recommended: float = 0.0
74
+ est_cost_premium: float = 0.0
75
+ baseline_model_id: str | None = None
76
+ est_cost_baseline_declared: float | None = None
77
+ # Context needed by the late-feedback degraded path (recstore TTL expired)
78
+ user_id: str | None = None
79
+ env_tags: list[str] = field(default_factory=list)
80
+ content: str = ""
81
+ # Reconciliation columns — NULL until feedback arrives
82
+ realized_model_id: str | None = None
83
+ realized_outcome: str | None = None
84
+ realized_quality: float | None = None
85
+ realized_cost_usd: float | None = None
86
+ realized_latency_ms: int | None = None
87
+ feedback_ts: float | None = None
88
+ late_feedback: bool = False
89
+
90
+ @property
91
+ def reconciled(self) -> bool:
92
+ return self.realized_outcome is not None
93
+
94
+ @property
95
+ def predicted_success_chosen(self) -> float | None:
96
+ for c in self.candidates:
97
+ if c.model_id == self.chosen_model_id:
98
+ return c.predicted_success
99
+ return None
100
+
101
+ @property
102
+ def raw_predicted_success_chosen(self) -> float | None:
103
+ """Pre-calibration Beta mean for the chosen model (the quantity calibration fits on).
104
+
105
+ Falls back to the deployed ``predicted_success`` for rows logged before the raw
106
+ value was captured, so historical rows still contribute (slightly biased) pairs.
107
+ """
108
+ for c in self.candidates:
109
+ if c.model_id == self.chosen_model_id:
110
+ if c.raw_predicted_success is not None:
111
+ return c.raw_predicted_success
112
+ return c.predicted_success
113
+ return None
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class Reconciliation:
118
+ """Realized-outcome fields applied to a decision row at feedback time."""
119
+
120
+ model_id: str
121
+ outcome: str
122
+ quality: float
123
+ cost_usd: float | None = None
124
+ latency_ms: int | None = None
125
+ ts: float = 0.0
126
+ late: bool = False
127
+
128
+
129
+ @runtime_checkable
130
+ class DecisionLog(Protocol):
131
+ def put(self, rec: DecisionRecord) -> None: ...
132
+
133
+ def get(self, recommendation_id: str) -> DecisionRecord | None: ...
134
+
135
+ def reconcile(self, recommendation_id: str, update: Reconciliation) -> bool: ...
136
+
137
+ def rows(
138
+ self,
139
+ *,
140
+ since: float | None = None,
141
+ until: float | None = None,
142
+ lane: str | None = None,
143
+ ) -> list[DecisionRecord]: ...
144
+
145
+
146
+ def _serialize(rec: DecisionRecord) -> str:
147
+ data = asdict(rec)
148
+ return json.dumps(data)
149
+
150
+
151
+ def _deserialize(payload: str) -> DecisionRecord:
152
+ d = json.loads(payload)
153
+ d["candidates"] = [CandidateSnapshot(**c) for c in d.get("candidates") or []]
154
+ return DecisionRecord(**d)
155
+
156
+
157
+ def _apply(rec: DecisionRecord, update: Reconciliation) -> None:
158
+ rec.realized_model_id = update.model_id
159
+ rec.realized_outcome = update.outcome
160
+ rec.realized_quality = update.quality
161
+ rec.realized_cost_usd = update.cost_usd
162
+ rec.realized_latency_ms = update.latency_ms
163
+ rec.feedback_ts = update.ts or time.time()
164
+ rec.late_feedback = update.late
165
+
166
+
167
+ # Retention purge runs at most this often (the log is written on EVERY recommendation;
168
+ # purging on each write would put an O(n) scan / DELETE on the hot path).
169
+ _PURGE_INTERVAL_S = 300.0
170
+
171
+
172
+ class MemoryDecisionLog:
173
+ """In-process decision log (lost on restart)."""
174
+
175
+ def __init__(self, retention_days: int = 90):
176
+ self._retention = retention_days * _SECONDS_PER_DAY
177
+ self._data: dict[str, DecisionRecord] = {}
178
+ self._lock = Lock()
179
+ self._last_purge = 0.0
180
+
181
+ def put(self, rec: DecisionRecord, org_id: str | None = None) -> None:
182
+ if org_id is not None:
183
+ rec.org_id = org_id
184
+ if rec.ts == 0.0:
185
+ rec.ts = time.time()
186
+ with self._lock:
187
+ if time.time() - self._last_purge > _PURGE_INTERVAL_S:
188
+ self._purge_locked()
189
+ self._last_purge = time.time()
190
+ self._data[rec.recommendation_id] = rec
191
+
192
+ def get(self, recommendation_id: str, org_id: str | None = None) -> DecisionRecord | None:
193
+ with self._lock:
194
+ rec = self._data.get(recommendation_id)
195
+ if rec is None:
196
+ return None
197
+ if org_id is not None and rec.org_id != org_id:
198
+ return None
199
+ return rec
200
+
201
+ def reconcile(
202
+ self, recommendation_id: str, update: Reconciliation, org_id: str | None = None
203
+ ) -> bool:
204
+ with self._lock:
205
+ rec = self._data.get(recommendation_id)
206
+ if rec is None or (org_id is not None and rec.org_id != org_id):
207
+ return False
208
+ _apply(rec, update)
209
+ return True
210
+
211
+ def rows(
212
+ self,
213
+ *,
214
+ since: float | None = None,
215
+ until: float | None = None,
216
+ lane: str | None = None,
217
+ org_id: str | None = None,
218
+ ) -> list[DecisionRecord]:
219
+ with self._lock:
220
+ items = [
221
+ r
222
+ for r in self._data.values()
223
+ if org_id is None or r.org_id == org_id
224
+ ]
225
+ out = []
226
+ for rec in items:
227
+ if since is not None and rec.ts < since:
228
+ continue
229
+ if until is not None and rec.ts > until:
230
+ continue
231
+ if lane is not None and rec.lane != lane:
232
+ continue
233
+ out.append(rec)
234
+ out.sort(key=lambda r: r.ts)
235
+ return out
236
+
237
+ def _purge_locked(self) -> None:
238
+ cutoff = time.time() - self._retention
239
+ expired = [k for k, v in self._data.items() if v.ts and v.ts < cutoff]
240
+ for k in expired:
241
+ self._data.pop(k, None)
242
+
243
+
244
+ class SqliteDecisionLog:
245
+ """Durable decision log backed by SQLite (stdlib; shares the recstore DB file)."""
246
+
247
+ def __init__(self, path: str, retention_days: int = 90):
248
+ self._retention = retention_days * _SECONDS_PER_DAY
249
+ self._conn = sqlite3.connect(path, check_same_thread=False)
250
+ self._lock = Lock()
251
+ self._last_purge = 0.0
252
+ with self._conn:
253
+ self._conn.execute(
254
+ """
255
+ CREATE TABLE IF NOT EXISTS decisions (
256
+ recommendation_id TEXT PRIMARY KEY,
257
+ org_id TEXT NOT NULL DEFAULT 'default',
258
+ ts REAL NOT NULL,
259
+ lane TEXT NOT NULL DEFAULT '',
260
+ payload TEXT NOT NULL
261
+ )
262
+ """
263
+ )
264
+ self._conn.execute(
265
+ "CREATE INDEX IF NOT EXISTS ix_decisions_org_ts ON decisions(org_id, ts)"
266
+ )
267
+
268
+ def put(self, rec: DecisionRecord, org_id: str | None = None) -> None:
269
+ if org_id is not None:
270
+ rec.org_id = org_id
271
+ if rec.ts == 0.0:
272
+ rec.ts = time.time()
273
+ with self._lock, self._conn:
274
+ if time.time() - self._last_purge > _PURGE_INTERVAL_S:
275
+ self._conn.execute(
276
+ "DELETE FROM decisions WHERE ts < ?", (time.time() - self._retention,)
277
+ )
278
+ self._last_purge = time.time()
279
+ self._conn.execute(
280
+ "INSERT OR REPLACE INTO decisions (recommendation_id, org_id, ts, lane, payload)"
281
+ " VALUES (?, ?, ?, ?, ?)",
282
+ (rec.recommendation_id, rec.org_id, rec.ts, rec.lane, _serialize(rec)),
283
+ )
284
+
285
+ def get(self, recommendation_id: str, org_id: str | None = None) -> DecisionRecord | None:
286
+ with self._lock:
287
+ row = self._conn.execute(
288
+ "SELECT payload, org_id FROM decisions WHERE recommendation_id = ?",
289
+ (recommendation_id,),
290
+ ).fetchone()
291
+ if row is None:
292
+ return None
293
+ if org_id is not None and str(row[1]) != org_id:
294
+ return None
295
+ return _deserialize(str(row[0]))
296
+
297
+ def reconcile(
298
+ self, recommendation_id: str, update: Reconciliation, org_id: str | None = None
299
+ ) -> bool:
300
+ with self._lock, self._conn:
301
+ row = self._conn.execute(
302
+ "SELECT payload, org_id FROM decisions WHERE recommendation_id = ?",
303
+ (recommendation_id,),
304
+ ).fetchone()
305
+ if row is None or (org_id is not None and str(row[1]) != org_id):
306
+ return False
307
+ rec = _deserialize(str(row[0]))
308
+ _apply(rec, update)
309
+ self._conn.execute(
310
+ "UPDATE decisions SET payload = ? WHERE recommendation_id = ?",
311
+ (_serialize(rec), recommendation_id),
312
+ )
313
+ return True
314
+
315
+ def rows(
316
+ self,
317
+ *,
318
+ since: float | None = None,
319
+ until: float | None = None,
320
+ lane: str | None = None,
321
+ org_id: str | None = None,
322
+ ) -> list[DecisionRecord]:
323
+ clauses: list[str] = []
324
+ params: list[str | float] = []
325
+ if org_id is not None:
326
+ clauses.append("org_id = ?")
327
+ params.append(org_id)
328
+ if since is not None:
329
+ clauses.append("ts >= ?")
330
+ params.append(since)
331
+ if until is not None:
332
+ clauses.append("ts <= ?")
333
+ params.append(until)
334
+ if lane is not None:
335
+ clauses.append("lane = ?")
336
+ params.append(lane)
337
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
338
+ with self._lock:
339
+ rows = self._conn.execute(
340
+ f"SELECT payload FROM decisions {where} ORDER BY ts", params # noqa: S608
341
+ ).fetchall()
342
+ return [_deserialize(str(r[0])) for r in rows]
343
+
344
+ def close(self) -> None:
345
+ with self._lock:
346
+ self._conn.close()
347
+
348
+
349
+ class OrgScopedDecisionLog:
350
+ """Binds a shared decision-log backend to one org (the tenant-isolation guard)."""
351
+
352
+ def __init__(self, backend: DecisionLog, org_id: str):
353
+ self._backend = backend
354
+ self._org_id = org_id
355
+
356
+ def put(self, rec: DecisionRecord) -> None:
357
+ self._backend.put(rec, self._org_id) # type: ignore[call-arg]
358
+
359
+ def get(self, recommendation_id: str) -> DecisionRecord | None:
360
+ return self._backend.get(recommendation_id, self._org_id) # type: ignore[call-arg]
361
+
362
+ def reconcile(self, recommendation_id: str, update: Reconciliation) -> bool:
363
+ return self._backend.reconcile(recommendation_id, update, self._org_id) # type: ignore[call-arg]
364
+
365
+ def rows(
366
+ self,
367
+ *,
368
+ since: float | None = None,
369
+ until: float | None = None,
370
+ lane: str | None = None,
371
+ ) -> list[DecisionRecord]:
372
+ return self._backend.rows( # type: ignore[call-arg]
373
+ since=since, until=until, lane=lane, org_id=self._org_id
374
+ )
375
+
376
+
377
+ class PostgresDecisionLog:
378
+ """Durable decision log backed by PostgreSQL (Cloud SQL via Auth Proxy).
379
+
380
+ Shares the same database as the other Postgres stores; each store owns its table.
381
+ """
382
+
383
+ def __init__(self, database_url: str, retention_days: int = 90):
384
+ from minima.recommender._pg_pool import cursor as _cursor
385
+
386
+ self._retention = retention_days * _SECONDS_PER_DAY
387
+ self._url = database_url
388
+ self._cursor = _cursor
389
+ self._last_purge = 0.0
390
+ with self._cursor(self._url) as cur:
391
+ cur.execute(
392
+ """
393
+ CREATE TABLE IF NOT EXISTS decisions (
394
+ recommendation_id TEXT PRIMARY KEY,
395
+ org_id TEXT NOT NULL DEFAULT 'default',
396
+ ts DOUBLE PRECISION NOT NULL,
397
+ lane TEXT NOT NULL DEFAULT '',
398
+ payload TEXT NOT NULL
399
+ )
400
+ """
401
+ )
402
+ cur.execute(
403
+ "CREATE INDEX IF NOT EXISTS ix_decisions_org_ts ON decisions(org_id, ts)"
404
+ )
405
+
406
+ def put(self, rec: DecisionRecord, org_id: str | None = None) -> None:
407
+ if org_id is not None:
408
+ rec.org_id = org_id
409
+ if rec.ts == 0.0:
410
+ rec.ts = time.time()
411
+ with self._cursor(self._url) as cur:
412
+ if time.time() - self._last_purge > _PURGE_INTERVAL_S:
413
+ cur.execute(
414
+ "DELETE FROM decisions WHERE ts < %s", (time.time() - self._retention,)
415
+ )
416
+ self._last_purge = time.time()
417
+ cur.execute(
418
+ """
419
+ INSERT INTO decisions (recommendation_id, org_id, ts, lane, payload)
420
+ VALUES (%s, %s, %s, %s, %s)
421
+ ON CONFLICT (recommendation_id) DO UPDATE SET
422
+ org_id = EXCLUDED.org_id,
423
+ ts = EXCLUDED.ts,
424
+ lane = EXCLUDED.lane,
425
+ payload = EXCLUDED.payload
426
+ """,
427
+ (rec.recommendation_id, rec.org_id, rec.ts, rec.lane, _serialize(rec)),
428
+ )
429
+
430
+ def get(self, recommendation_id: str, org_id: str | None = None) -> DecisionRecord | None:
431
+ with self._cursor(self._url) as cur:
432
+ cur.execute(
433
+ "SELECT payload, org_id FROM decisions WHERE recommendation_id = %s",
434
+ (recommendation_id,),
435
+ )
436
+ row = cur.fetchone()
437
+ if row is None:
438
+ return None
439
+ if org_id is not None and str(row[1]) != org_id:
440
+ return None
441
+ return _deserialize(str(row[0]))
442
+
443
+ def reconcile(
444
+ self, recommendation_id: str, update: Reconciliation, org_id: str | None = None
445
+ ) -> bool:
446
+ with self._cursor(self._url) as cur:
447
+ cur.execute(
448
+ "SELECT payload, org_id FROM decisions WHERE recommendation_id = %s",
449
+ (recommendation_id,),
450
+ )
451
+ row = cur.fetchone()
452
+ if row is None or (org_id is not None and str(row[1]) != org_id):
453
+ return False
454
+ rec = _deserialize(str(row[0]))
455
+ _apply(rec, update)
456
+ cur.execute(
457
+ "UPDATE decisions SET payload = %s WHERE recommendation_id = %s",
458
+ (_serialize(rec), recommendation_id),
459
+ )
460
+ return True
461
+
462
+ def rows(
463
+ self,
464
+ *,
465
+ since: float | None = None,
466
+ until: float | None = None,
467
+ lane: str | None = None,
468
+ org_id: str | None = None,
469
+ ) -> list[DecisionRecord]:
470
+ clauses: list[str] = []
471
+ params: list[str | float] = []
472
+ if org_id is not None:
473
+ clauses.append("org_id = %s")
474
+ params.append(org_id)
475
+ if since is not None:
476
+ clauses.append("ts >= %s")
477
+ params.append(since)
478
+ if until is not None:
479
+ clauses.append("ts <= %s")
480
+ params.append(until)
481
+ if lane is not None:
482
+ clauses.append("lane = %s")
483
+ params.append(lane)
484
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
485
+ with self._cursor(self._url) as cur:
486
+ cur.execute(
487
+ f"SELECT payload FROM decisions {where} ORDER BY ts", # noqa: S608
488
+ params,
489
+ )
490
+ rows = cur.fetchall()
491
+ return [_deserialize(str(r[0])) for r in rows]
492
+
493
+
494
+ def build_decision_log(settings: Settings) -> DecisionLog:
495
+ retention = settings.minima_decision_log_retention_days
496
+ backend = settings.minima_recommendation_store.strip().lower()
497
+ if backend in ("cloudsql", "postgres", "postgresql"):
498
+ if not settings.minima_database_url:
499
+ raise RuntimeError(
500
+ "MINIMA_DATABASE_URL is required when MINIMA_RECOMMENDATION_STORE=cloudsql"
501
+ )
502
+ return PostgresDecisionLog(settings.minima_database_url, retention)
503
+ if backend == "sqlite":
504
+ return SqliteDecisionLog(settings.minima_sqlite_path, retention)
505
+ return MemoryDecisionLog(retention)