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.
- minima/__init__.py +5 -0
- minima/api/__init__.py +1 -0
- minima/api/auth.py +39 -0
- minima/api/errors.py +40 -0
- minima/api/routers/__init__.py +1 -0
- minima/api/routers/calibration.py +50 -0
- minima/api/routers/feedback.py +279 -0
- minima/api/routers/health.py +50 -0
- minima/api/routers/models.py +42 -0
- minima/api/routers/recommend.py +66 -0
- minima/api/routers/savings.py +55 -0
- minima/api/routers/strategies.py +33 -0
- minima/catalog/__init__.py +1 -0
- minima/catalog/data/capability_priors.json +210 -0
- minima/catalog/data/model_aliases.json +12 -0
- minima/catalog/merge.py +69 -0
- minima/catalog/refresh.py +54 -0
- minima/catalog/sources/__init__.py +1 -0
- minima/catalog/sources/litellm.py +19 -0
- minima/catalog/sources/openrouter.py +25 -0
- minima/catalog/store.py +86 -0
- minima/config.py +288 -0
- minima/deps.py +35 -0
- minima/llm/__init__.py +1 -0
- minima/llm/anthropic.py +106 -0
- minima/llm/base.py +196 -0
- minima/llm/gemini.py +124 -0
- minima/llm/registry.py +54 -0
- minima/logging.py +28 -0
- minima/main.py +109 -0
- minima/memory/__init__.py +1 -0
- minima/memory/adapter.py +572 -0
- minima/memory/keys.py +83 -0
- minima/memory/records.py +190 -0
- minima/memory/threadpool.py +41 -0
- minima/metrics/__init__.py +1 -0
- minima/metrics/calibration.py +415 -0
- minima/metrics/report.py +116 -0
- minima/metrics/savings.py +98 -0
- minima/recommender/__init__.py +1 -0
- minima/recommender/_pg_pool.py +38 -0
- minima/recommender/_redis_client.py +32 -0
- minima/recommender/aggregate.py +157 -0
- minima/recommender/classify.py +165 -0
- minima/recommender/decisionlog.py +505 -0
- minima/recommender/durablerefs.py +312 -0
- minima/recommender/engine.py +997 -0
- minima/recommender/escalation.py +83 -0
- minima/recommender/propensity.py +189 -0
- minima/recommender/recstore.py +368 -0
- minima/recommender/score.py +318 -0
- minima/recommender/types.py +166 -0
- minima/schemas/__init__.py +1 -0
- minima/schemas/common.py +73 -0
- minima/schemas/feedback.py +34 -0
- minima/schemas/models_catalog.py +36 -0
- minima/schemas/recommend.py +104 -0
- minima/schemas/savings.py +39 -0
- minima/schemas/strategies.py +57 -0
- minima/schemas/workflow.py +43 -0
- minima/seeding/__init__.py +1 -0
- minima/seeding/items.py +42 -0
- minima/seeding/llmrouterbench.py +232 -0
- minima/seeding/routerbench.py +141 -0
- minima/seeding/run_seed.py +56 -0
- minima/seeding/synthetic.py +70 -0
- minima/tenancy/__init__.py +8 -0
- minima/tenancy/context.py +37 -0
- minima/tenancy/passthrough.py +110 -0
- minima/version.py +3 -0
- minima_cli-0.4.9.dist-info/METADATA +275 -0
- minima_cli-0.4.9.dist-info/RECORD +161 -0
- minima_cli-0.4.9.dist-info/WHEEL +4 -0
- minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
- minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
- minima_client/__init__.py +19 -0
- minima_client/autocapture.py +101 -0
- minima_client/client.py +301 -0
- minima_client/errors.py +23 -0
- minima_harness/LICENSE_PI +32 -0
- minima_harness/__init__.py +16 -0
- minima_harness/agent/__init__.py +72 -0
- minima_harness/agent/agent.py +276 -0
- minima_harness/agent/events.py +124 -0
- minima_harness/agent/loop.py +311 -0
- minima_harness/agent/state.py +79 -0
- minima_harness/agent/tools.py +97 -0
- minima_harness/ai/__init__.py +66 -0
- minima_harness/ai/compat.py +71 -0
- minima_harness/ai/errors.py +96 -0
- minima_harness/ai/events.py +117 -0
- minima_harness/ai/openrouter_catalog.py +153 -0
- minima_harness/ai/provider_catalog.py +299 -0
- minima_harness/ai/provider_quirks.py +37 -0
- minima_harness/ai/providers/__init__.py +75 -0
- minima_harness/ai/providers/_common.py +48 -0
- minima_harness/ai/providers/anthropic.py +290 -0
- minima_harness/ai/providers/base.py +65 -0
- minima_harness/ai/providers/faux.py +173 -0
- minima_harness/ai/providers/google.py +221 -0
- minima_harness/ai/providers/openai_compat.py +278 -0
- minima_harness/ai/registry.py +184 -0
- minima_harness/ai/stream.py +82 -0
- minima_harness/ai/tools.py +51 -0
- minima_harness/ai/types.py +204 -0
- minima_harness/ai/usage.py +41 -0
- minima_harness/minima/__init__.py +40 -0
- minima_harness/minima/cache.py +102 -0
- minima_harness/minima/config.py +85 -0
- minima_harness/minima/goals.py +226 -0
- minima_harness/minima/judge.py +144 -0
- minima_harness/minima/mapping.py +147 -0
- minima_harness/minima/meter.py +143 -0
- minima_harness/minima/router.py +220 -0
- minima_harness/minima/runtime.py +544 -0
- minima_harness/minima/signals.py +195 -0
- minima_harness/session/__init__.py +14 -0
- minima_harness/session/format.py +35 -0
- minima_harness/session/store.py +236 -0
- minima_harness/tasks/__init__.py +17 -0
- minima_harness/tasks/task_set.py +78 -0
- minima_harness/tools/__init__.py +7 -0
- minima_harness/tools/_io.py +34 -0
- minima_harness/tools/bash.py +70 -0
- minima_harness/tools/builtin.py +23 -0
- minima_harness/tools/edit.py +50 -0
- minima_harness/tools/find.py +38 -0
- minima_harness/tools/grep.py +73 -0
- minima_harness/tools/ls.py +35 -0
- minima_harness/tools/read.py +38 -0
- minima_harness/tools/tasks.py +75 -0
- minima_harness/tools/write.py +36 -0
- minima_harness/tui/__init__.py +3 -0
- minima_harness/tui/analytics.py +111 -0
- minima_harness/tui/app.py +1927 -0
- minima_harness/tui/bridge.py +103 -0
- minima_harness/tui/cli.py +227 -0
- minima_harness/tui/clipboard.py +60 -0
- minima_harness/tui/commands.py +49 -0
- minima_harness/tui/compaction.py +17 -0
- minima_harness/tui/config_cli.py +141 -0
- minima_harness/tui/config_store.py +237 -0
- minima_harness/tui/context.py +93 -0
- minima_harness/tui/customize.py +95 -0
- minima_harness/tui/diff.py +53 -0
- minima_harness/tui/editor.py +43 -0
- minima_harness/tui/extensions.py +84 -0
- minima_harness/tui/extra_models.py +52 -0
- minima_harness/tui/history.py +71 -0
- minima_harness/tui/mubit.py +295 -0
- minima_harness/tui/overlays.py +593 -0
- minima_harness/tui/packages.py +59 -0
- minima_harness/tui/run_modes.py +66 -0
- minima_harness/tui/theme.py +77 -0
- minima_harness/tui/welcome.py +83 -0
- minima_harness/tui/widgets/__init__.py +3 -0
- minima_harness/tui/widgets/banner.py +38 -0
- minima_harness/tui/widgets/editor.py +83 -0
- minima_harness/tui/widgets/footer.py +73 -0
- minima_harness/tui/widgets/messages.py +151 -0
- 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)
|