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,83 @@
|
|
|
1
|
+
"""When to escalate a recommendation to the cheap-LLM reasoner.
|
|
2
|
+
|
|
3
|
+
Phase 1 computes the triggers but does not call a reasoner (provider defaults to
|
|
4
|
+
``none``); the engine records the reasons as warnings and keeps the deterministic pick.
|
|
5
|
+
The reasoner itself is wired in a later phase.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from minima.config import Settings
|
|
13
|
+
from minima.recommender.aggregate import is_conflicted
|
|
14
|
+
from minima.recommender.types import CandidateScore, ModelAggregate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class EscalationDecision:
|
|
19
|
+
should_escalate: bool = False
|
|
20
|
+
reasons: list[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def evaluate(
|
|
24
|
+
*,
|
|
25
|
+
settings: Settings,
|
|
26
|
+
allow: bool,
|
|
27
|
+
total_weight: float,
|
|
28
|
+
distinct_models_with_evidence: int,
|
|
29
|
+
recommended_confidence: float,
|
|
30
|
+
ranked: list[CandidateScore],
|
|
31
|
+
aggregates: dict[str, ModelAggregate],
|
|
32
|
+
recommended_interval_width: float | None = None,
|
|
33
|
+
recommended_predicted_success: float = 0.0,
|
|
34
|
+
tau: float = 0.0,
|
|
35
|
+
) -> EscalationDecision:
|
|
36
|
+
"""Decide whether the cheap-LLM reasoner should be consulted.
|
|
37
|
+
|
|
38
|
+
Two modes. "legacy": four independent heuristics. "uncertainty": a single
|
|
39
|
+
posterior-interval-width gate on the recommended candidate replaces the
|
|
40
|
+
thin_evidence + low_confidence pair (the interval IS the principled "how little do
|
|
41
|
+
we know" statistic); conflict stays as a hard override and tie is kept because it
|
|
42
|
+
captures rank instability between candidates that the per-candidate interval
|
|
43
|
+
doesn't see. Every escalation is a paid reasoner call — fewer, better-targeted
|
|
44
|
+
triggers are the efficiency lever.
|
|
45
|
+
"""
|
|
46
|
+
decision = EscalationDecision()
|
|
47
|
+
if not allow:
|
|
48
|
+
return decision
|
|
49
|
+
|
|
50
|
+
uncertainty_mode = settings.minima_escalation_mode.lower() == "uncertainty"
|
|
51
|
+
if uncertainty_mode and recommended_interval_width is not None:
|
|
52
|
+
if recommended_interval_width > settings.minima_escalation_interval_width:
|
|
53
|
+
decision.reasons.append("wide_interval")
|
|
54
|
+
else:
|
|
55
|
+
if (
|
|
56
|
+
total_weight < settings.minima_escalation_w_min
|
|
57
|
+
or distinct_models_with_evidence < settings.minima_escalation_n_min
|
|
58
|
+
):
|
|
59
|
+
decision.reasons.append("thin_evidence")
|
|
60
|
+
|
|
61
|
+
if recommended_confidence < settings.minima_escalation_c_min:
|
|
62
|
+
decision.reasons.append("low_confidence")
|
|
63
|
+
|
|
64
|
+
near_delta = settings.minima_escalation_near_threshold_delta
|
|
65
|
+
if (
|
|
66
|
+
near_delta > 0
|
|
67
|
+
and tau > 0
|
|
68
|
+
and recommended_predicted_success > 0
|
|
69
|
+
and recommended_confidence > 0.2 # only when there's actual evidence, not a cold prior
|
|
70
|
+
and (recommended_predicted_success - tau) < near_delta
|
|
71
|
+
):
|
|
72
|
+
decision.reasons.append("near_threshold")
|
|
73
|
+
|
|
74
|
+
if len(ranked) >= 2:
|
|
75
|
+
gap = ranked[0].score - ranked[1].score
|
|
76
|
+
if gap < settings.minima_escalation_tie_delta:
|
|
77
|
+
decision.reasons.append("tie")
|
|
78
|
+
|
|
79
|
+
if any(is_conflicted(agg) for agg in aggregates.values()):
|
|
80
|
+
decision.reasons.append("conflict")
|
|
81
|
+
|
|
82
|
+
decision.should_escalate = bool(decision.reasons)
|
|
83
|
+
return decision
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Logging-propensity tracker for inverse-propensity-weighting bias correction.
|
|
2
|
+
|
|
3
|
+
Minima only observes outcomes for models it recommended, so frequently-recommended
|
|
4
|
+
models accumulate more evidence than rarely-tried ones. IPW re-weights each model's
|
|
5
|
+
recalled evidence by 1/propensity (clipped) so under-explored models aren't unfairly
|
|
6
|
+
buried. An in-process tracker is the default; a SQLite-backed tracker persists the
|
|
7
|
+
counts so the bias correction survives restarts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from threading import Lock
|
|
15
|
+
from typing import Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
from minima.config import Settings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Propensity(Protocol):
|
|
22
|
+
def record(self, lane: str, cluster: str, model_id: str) -> None: ...
|
|
23
|
+
|
|
24
|
+
def propensities(self, lane: str, cluster: str, model_ids: Iterable[str]) -> dict[str, float]:
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _laplace_shares(bucket: dict[str, int], ids: list[str]) -> dict[str, float]:
|
|
29
|
+
"""Laplace-smoothed share of recommendations per model within a (lane, cluster)."""
|
|
30
|
+
m = len(ids) or 1
|
|
31
|
+
total = sum(bucket.get(mid, 0) for mid in ids)
|
|
32
|
+
denom = total + m
|
|
33
|
+
return {mid: (bucket.get(mid, 0) + 1) / denom for mid in ids}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PropensityTracker:
|
|
37
|
+
"""In-process propensity counts (lost on restart)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._counts: dict[tuple[str, str, str], dict[str, int]] = {}
|
|
41
|
+
self._lock = Lock()
|
|
42
|
+
|
|
43
|
+
def record(self, lane: str, cluster: str, model_id: str, org_id: str = "default") -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
bucket = self._counts.setdefault((org_id, lane, cluster), {})
|
|
46
|
+
bucket[model_id] = bucket.get(model_id, 0) + 1
|
|
47
|
+
|
|
48
|
+
def propensities(
|
|
49
|
+
self, lane: str, cluster: str, model_ids: Iterable[str], org_id: str = "default"
|
|
50
|
+
) -> dict[str, float]:
|
|
51
|
+
ids = list(model_ids)
|
|
52
|
+
with self._lock:
|
|
53
|
+
bucket = dict(self._counts.get((org_id, lane, cluster), {}))
|
|
54
|
+
return _laplace_shares(bucket, ids)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SqlitePropensityTracker:
|
|
58
|
+
"""Durable propensity counts backed by SQLite (stdlib, no extra dependency)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, path: str):
|
|
61
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
62
|
+
self._lock = Lock()
|
|
63
|
+
with self._conn:
|
|
64
|
+
self._conn.execute(
|
|
65
|
+
"""
|
|
66
|
+
CREATE TABLE IF NOT EXISTS propensity (
|
|
67
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
68
|
+
lane TEXT NOT NULL,
|
|
69
|
+
cluster TEXT NOT NULL,
|
|
70
|
+
model_id TEXT NOT NULL,
|
|
71
|
+
count INTEGER NOT NULL DEFAULT 0
|
|
72
|
+
)
|
|
73
|
+
"""
|
|
74
|
+
)
|
|
75
|
+
# Add org_id to a pre-existing (dev) DB that predates multi-tenancy.
|
|
76
|
+
cols = {row[1] for row in self._conn.execute("PRAGMA table_info(propensity)")}
|
|
77
|
+
if "org_id" not in cols:
|
|
78
|
+
self._conn.execute(
|
|
79
|
+
"ALTER TABLE propensity ADD COLUMN org_id TEXT NOT NULL DEFAULT 'default'"
|
|
80
|
+
)
|
|
81
|
+
# Upsert target — works regardless of the original PRIMARY KEY shape.
|
|
82
|
+
self._conn.execute(
|
|
83
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS ux_propensity "
|
|
84
|
+
"ON propensity(org_id, lane, cluster, model_id)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def record(self, lane: str, cluster: str, model_id: str, org_id: str = "default") -> None:
|
|
88
|
+
with self._lock, self._conn:
|
|
89
|
+
self._conn.execute(
|
|
90
|
+
"""
|
|
91
|
+
INSERT INTO propensity (org_id, lane, cluster, model_id, count)
|
|
92
|
+
VALUES (?, ?, ?, ?, 1)
|
|
93
|
+
ON CONFLICT(org_id, lane, cluster, model_id) DO UPDATE SET count = count + 1
|
|
94
|
+
""",
|
|
95
|
+
(org_id, lane, cluster, model_id),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def propensities(
|
|
99
|
+
self, lane: str, cluster: str, model_ids: Iterable[str], org_id: str = "default"
|
|
100
|
+
) -> dict[str, float]:
|
|
101
|
+
ids = list(model_ids)
|
|
102
|
+
with self._lock:
|
|
103
|
+
rows = self._conn.execute(
|
|
104
|
+
"SELECT model_id, count FROM propensity "
|
|
105
|
+
"WHERE org_id = ? AND lane = ? AND cluster = ?",
|
|
106
|
+
(org_id, lane, cluster),
|
|
107
|
+
).fetchall()
|
|
108
|
+
bucket = {str(mid): int(cnt) for mid, cnt in rows}
|
|
109
|
+
return _laplace_shares(bucket, ids)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._conn.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class OrgScopedPropensity:
|
|
117
|
+
"""Binds a shared propensity backend to one org, presenting the ``Propensity`` Protocol."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, backend: Propensity, org_id: str):
|
|
120
|
+
self._backend = backend
|
|
121
|
+
self._org_id = org_id
|
|
122
|
+
|
|
123
|
+
def record(self, lane: str, cluster: str, model_id: str) -> None:
|
|
124
|
+
self._backend.record(lane, cluster, model_id, self._org_id) # type: ignore[call-arg]
|
|
125
|
+
|
|
126
|
+
def propensities(self, lane: str, cluster: str, model_ids: Iterable[str]) -> dict[str, float]:
|
|
127
|
+
return self._backend.propensities(lane, cluster, model_ids, self._org_id) # type: ignore[call-arg]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PostgresPropensityTracker:
|
|
131
|
+
"""Durable propensity counts backed by PostgreSQL."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, database_url: str):
|
|
134
|
+
from minima.recommender._pg_pool import cursor as _cursor
|
|
135
|
+
|
|
136
|
+
self._url = database_url
|
|
137
|
+
self._cursor = _cursor
|
|
138
|
+
with self._cursor(self._url) as cur:
|
|
139
|
+
cur.execute(
|
|
140
|
+
"""
|
|
141
|
+
CREATE TABLE IF NOT EXISTS propensity (
|
|
142
|
+
org_id TEXT NOT NULL DEFAULT 'default',
|
|
143
|
+
lane TEXT NOT NULL,
|
|
144
|
+
cluster TEXT NOT NULL,
|
|
145
|
+
model_id TEXT NOT NULL,
|
|
146
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
147
|
+
UNIQUE(org_id, lane, cluster, model_id)
|
|
148
|
+
)
|
|
149
|
+
"""
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def record(self, lane: str, cluster: str, model_id: str, org_id: str = "default") -> None:
|
|
153
|
+
with self._cursor(self._url) as cur:
|
|
154
|
+
cur.execute(
|
|
155
|
+
"""
|
|
156
|
+
INSERT INTO propensity (org_id, lane, cluster, model_id, count)
|
|
157
|
+
VALUES (%s, %s, %s, %s, 1)
|
|
158
|
+
ON CONFLICT (org_id, lane, cluster, model_id) DO UPDATE
|
|
159
|
+
SET count = propensity.count + 1
|
|
160
|
+
""",
|
|
161
|
+
(org_id, lane, cluster, model_id),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def propensities(
|
|
165
|
+
self, lane: str, cluster: str, model_ids: Iterable[str], org_id: str = "default"
|
|
166
|
+
) -> dict[str, float]:
|
|
167
|
+
ids = list(model_ids)
|
|
168
|
+
with self._cursor(self._url) as cur:
|
|
169
|
+
cur.execute(
|
|
170
|
+
"SELECT model_id, count FROM propensity"
|
|
171
|
+
" WHERE org_id = %s AND lane = %s AND cluster = %s",
|
|
172
|
+
(org_id, lane, cluster),
|
|
173
|
+
)
|
|
174
|
+
rows = cur.fetchall()
|
|
175
|
+
bucket = {str(mid): int(cnt) for mid, cnt in rows}
|
|
176
|
+
return _laplace_shares(bucket, ids)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_propensity(settings: Settings) -> Propensity:
|
|
180
|
+
backend = settings.minima_recommendation_store.strip().lower()
|
|
181
|
+
if backend in ("cloudsql", "postgres", "postgresql"):
|
|
182
|
+
if not settings.minima_database_url:
|
|
183
|
+
raise RuntimeError(
|
|
184
|
+
"MINIMA_DATABASE_URL is required when MINIMA_RECOMMENDATION_STORE=cloudsql"
|
|
185
|
+
)
|
|
186
|
+
return PostgresPropensityTracker(settings.minima_database_url)
|
|
187
|
+
if backend == "sqlite":
|
|
188
|
+
return SqlitePropensityTracker(settings.minima_sqlite_path)
|
|
189
|
+
return PropensityTracker()
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Store mapping a recommendation_id to the evidence that produced it.
|
|
2
|
+
|
|
3
|
+
Feedback resolves a recommendation_id here to know exactly which Mubit entries to
|
|
4
|
+
credit, without the caller having to round-trip the neighbor ids. The default backend
|
|
5
|
+
is in-process with a TTL; a SQLite backend persists recommendations across restarts so
|
|
6
|
+
feedback that arrives after a redeploy still credits the right neighbors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from threading import Lock
|
|
16
|
+
from typing import Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from minima.config import Settings
|
|
19
|
+
from minima.logging import get_logger
|
|
20
|
+
|
|
21
|
+
log = get_logger("minima.recstore")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, decl: str) -> None:
|
|
25
|
+
"""Add ``column`` to ``table`` if a pre-existing (dev) DB predates it."""
|
|
26
|
+
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
27
|
+
if column not in cols:
|
|
28
|
+
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class StoredRecommendation:
|
|
33
|
+
recommendation_id: str
|
|
34
|
+
lane: str
|
|
35
|
+
user_id: str | None
|
|
36
|
+
task_type: str
|
|
37
|
+
difficulty: str
|
|
38
|
+
task_cluster: str
|
|
39
|
+
task_fingerprint: str
|
|
40
|
+
content: str
|
|
41
|
+
env_tags: list[str]
|
|
42
|
+
recommended_model_id: str
|
|
43
|
+
# model_id -> [(entry_id, reference_id), ...] recalled neighbors for that model
|
|
44
|
+
neighbors_by_model: dict[str, list[tuple[str, str | None]]] = field(default_factory=dict)
|
|
45
|
+
created_at: float = 0.0
|
|
46
|
+
# Owning org (multi-tenancy). "default" in single-tenant mode. A get scoped to a
|
|
47
|
+
# different org must NOT resolve this record — that is the cross-tenant guard on
|
|
48
|
+
# /v1/feedback (org A can't credit/poison org B's recommendation_id).
|
|
49
|
+
org_id: str = "default"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class RecStore(Protocol):
|
|
54
|
+
def put(self, rec: StoredRecommendation) -> None: ...
|
|
55
|
+
|
|
56
|
+
def get(self, recommendation_id: str) -> StoredRecommendation | None: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RecommendationStore:
|
|
60
|
+
def __init__(self, ttl_seconds: int = 86_400):
|
|
61
|
+
self._ttl = ttl_seconds
|
|
62
|
+
self._data: dict[str, StoredRecommendation] = {}
|
|
63
|
+
self._lock = Lock()
|
|
64
|
+
|
|
65
|
+
def put(self, rec: StoredRecommendation) -> None:
|
|
66
|
+
if rec.created_at == 0.0:
|
|
67
|
+
rec.created_at = time.monotonic()
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._purge_locked()
|
|
70
|
+
self._data[rec.recommendation_id] = rec
|
|
71
|
+
|
|
72
|
+
def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
|
|
73
|
+
with self._lock:
|
|
74
|
+
rec = self._data.get(recommendation_id)
|
|
75
|
+
if rec is None:
|
|
76
|
+
return None
|
|
77
|
+
if time.monotonic() - rec.created_at > self._ttl:
|
|
78
|
+
self._data.pop(recommendation_id, None)
|
|
79
|
+
return None
|
|
80
|
+
if org_id is not None and rec.org_id != org_id:
|
|
81
|
+
return None
|
|
82
|
+
return rec
|
|
83
|
+
|
|
84
|
+
def _purge_locked(self) -> None:
|
|
85
|
+
now = time.monotonic()
|
|
86
|
+
expired = [k for k, v in self._data.items() if now - v.created_at > self._ttl]
|
|
87
|
+
for k in expired:
|
|
88
|
+
self._data.pop(k, None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _serialize(rec: StoredRecommendation) -> str:
|
|
92
|
+
return json.dumps(
|
|
93
|
+
{
|
|
94
|
+
"lane": rec.lane,
|
|
95
|
+
"user_id": rec.user_id,
|
|
96
|
+
"task_type": rec.task_type,
|
|
97
|
+
"difficulty": rec.difficulty,
|
|
98
|
+
"task_cluster": rec.task_cluster,
|
|
99
|
+
"task_fingerprint": rec.task_fingerprint,
|
|
100
|
+
"content": rec.content,
|
|
101
|
+
"env_tags": rec.env_tags,
|
|
102
|
+
"recommended_model_id": rec.recommended_model_id,
|
|
103
|
+
"neighbors_by_model": {
|
|
104
|
+
mid: [[eid, ref] for (eid, ref) in pairs]
|
|
105
|
+
for mid, pairs in rec.neighbors_by_model.items()
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _deserialize(
|
|
112
|
+
rec_id: str, payload: str, created_at: float, org_id: str = "default"
|
|
113
|
+
) -> StoredRecommendation:
|
|
114
|
+
d = json.loads(payload)
|
|
115
|
+
return StoredRecommendation(
|
|
116
|
+
recommendation_id=rec_id,
|
|
117
|
+
lane=d["lane"],
|
|
118
|
+
user_id=d.get("user_id"),
|
|
119
|
+
task_type=d["task_type"],
|
|
120
|
+
difficulty=d["difficulty"],
|
|
121
|
+
task_cluster=d["task_cluster"],
|
|
122
|
+
task_fingerprint=d["task_fingerprint"],
|
|
123
|
+
content=d["content"],
|
|
124
|
+
env_tags=list(d.get("env_tags") or []),
|
|
125
|
+
recommended_model_id=d["recommended_model_id"],
|
|
126
|
+
neighbors_by_model={
|
|
127
|
+
mid: [(p[0], p[1]) for p in pairs]
|
|
128
|
+
for mid, pairs in (d.get("neighbors_by_model") or {}).items()
|
|
129
|
+
},
|
|
130
|
+
created_at=created_at,
|
|
131
|
+
org_id=org_id,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SqliteRecommendationStore:
|
|
136
|
+
"""Durable recommendation store backed by SQLite (stdlib; wall-clock TTL)."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, path: str, ttl_seconds: int = 86_400):
|
|
139
|
+
self._ttl = ttl_seconds
|
|
140
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
141
|
+
self._lock = Lock()
|
|
142
|
+
with self._conn:
|
|
143
|
+
self._conn.execute(
|
|
144
|
+
"""
|
|
145
|
+
CREATE TABLE IF NOT EXISTS recommendations (
|
|
146
|
+
recommendation_id TEXT PRIMARY KEY,
|
|
147
|
+
created_at REAL NOT NULL,
|
|
148
|
+
payload TEXT NOT NULL,
|
|
149
|
+
org_id TEXT NOT NULL DEFAULT 'default'
|
|
150
|
+
)
|
|
151
|
+
"""
|
|
152
|
+
)
|
|
153
|
+
_ensure_column(
|
|
154
|
+
self._conn, "recommendations", "org_id", "TEXT NOT NULL DEFAULT 'default'"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def put(self, rec: StoredRecommendation) -> None:
|
|
158
|
+
# Durable TTL uses wall-clock epoch (monotonic is meaningless across restarts).
|
|
159
|
+
created = time.time() if rec.created_at == 0.0 else rec.created_at
|
|
160
|
+
with self._lock, self._conn:
|
|
161
|
+
self._conn.execute(
|
|
162
|
+
"DELETE FROM recommendations WHERE created_at < ?", (time.time() - self._ttl,)
|
|
163
|
+
)
|
|
164
|
+
self._conn.execute(
|
|
165
|
+
"INSERT OR REPLACE INTO recommendations "
|
|
166
|
+
"(recommendation_id, created_at, payload, org_id) VALUES (?, ?, ?, ?)",
|
|
167
|
+
(rec.recommendation_id, created, _serialize(rec), rec.org_id),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
|
|
171
|
+
with self._lock:
|
|
172
|
+
row = self._conn.execute(
|
|
173
|
+
"SELECT created_at, payload, org_id FROM recommendations "
|
|
174
|
+
"WHERE recommendation_id = ?",
|
|
175
|
+
(recommendation_id,),
|
|
176
|
+
).fetchone()
|
|
177
|
+
if row is None:
|
|
178
|
+
return None
|
|
179
|
+
created_at, payload, row_org = float(row[0]), str(row[1]), str(row[2])
|
|
180
|
+
if time.time() - created_at > self._ttl:
|
|
181
|
+
with self._lock, self._conn:
|
|
182
|
+
self._conn.execute(
|
|
183
|
+
"DELETE FROM recommendations WHERE recommendation_id = ?", (recommendation_id,)
|
|
184
|
+
)
|
|
185
|
+
return None
|
|
186
|
+
if org_id is not None and row_org != org_id:
|
|
187
|
+
return None
|
|
188
|
+
return _deserialize(recommendation_id, payload, created_at, row_org)
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
with self._lock:
|
|
192
|
+
self._conn.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class OrgScopedRecStore:
|
|
196
|
+
"""Binds a shared recstore backend to one org, presenting the ``RecStore`` Protocol.
|
|
197
|
+
|
|
198
|
+
``put`` stamps the org onto the record; ``get`` resolves only records owned by this
|
|
199
|
+
org — so a feedback call authenticated as org A cannot resolve org B's
|
|
200
|
+
recommendation_id. Used in both modes (single-tenant binds org ``"default"``).
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, backend: RecStore, org_id: str):
|
|
204
|
+
self._backend = backend
|
|
205
|
+
self._org_id = org_id
|
|
206
|
+
|
|
207
|
+
def put(self, rec: StoredRecommendation) -> None:
|
|
208
|
+
rec.org_id = self._org_id
|
|
209
|
+
self._backend.put(rec)
|
|
210
|
+
|
|
211
|
+
def get(self, recommendation_id: str) -> StoredRecommendation | None:
|
|
212
|
+
return self._backend.get(recommendation_id, self._org_id) # type: ignore[call-arg]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class PostgresRecommendationStore:
|
|
216
|
+
"""Durable recommendation store backed by PostgreSQL (Cloud SQL via Auth Proxy)."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, database_url: str, ttl_seconds: int = 86_400):
|
|
219
|
+
from minima.recommender._pg_pool import cursor as _cursor
|
|
220
|
+
|
|
221
|
+
self._ttl = ttl_seconds
|
|
222
|
+
self._url = database_url
|
|
223
|
+
self._cursor = _cursor
|
|
224
|
+
with self._cursor(self._url) as cur:
|
|
225
|
+
cur.execute(
|
|
226
|
+
"""
|
|
227
|
+
CREATE TABLE IF NOT EXISTS recommendations (
|
|
228
|
+
recommendation_id TEXT PRIMARY KEY,
|
|
229
|
+
created_at DOUBLE PRECISION NOT NULL,
|
|
230
|
+
payload TEXT NOT NULL,
|
|
231
|
+
org_id TEXT NOT NULL DEFAULT 'default'
|
|
232
|
+
)
|
|
233
|
+
"""
|
|
234
|
+
)
|
|
235
|
+
cur.execute(
|
|
236
|
+
"CREATE INDEX IF NOT EXISTS ix_recs_created_at ON recommendations(created_at)"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def put(self, rec: StoredRecommendation) -> None:
|
|
240
|
+
import time
|
|
241
|
+
|
|
242
|
+
created = time.time() if rec.created_at == 0.0 else rec.created_at
|
|
243
|
+
with self._cursor(self._url) as cur:
|
|
244
|
+
cur.execute(
|
|
245
|
+
"DELETE FROM recommendations WHERE created_at < %s", (time.time() - self._ttl,)
|
|
246
|
+
)
|
|
247
|
+
cur.execute(
|
|
248
|
+
"""
|
|
249
|
+
INSERT INTO recommendations (recommendation_id, created_at, payload, org_id)
|
|
250
|
+
VALUES (%s, %s, %s, %s)
|
|
251
|
+
ON CONFLICT (recommendation_id) DO UPDATE SET
|
|
252
|
+
created_at = EXCLUDED.created_at,
|
|
253
|
+
payload = EXCLUDED.payload,
|
|
254
|
+
org_id = EXCLUDED.org_id
|
|
255
|
+
""",
|
|
256
|
+
(rec.recommendation_id, created, _serialize(rec), rec.org_id),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
|
|
260
|
+
import time
|
|
261
|
+
|
|
262
|
+
with self._cursor(self._url) as cur:
|
|
263
|
+
cur.execute(
|
|
264
|
+
"SELECT created_at, payload, org_id FROM recommendations"
|
|
265
|
+
" WHERE recommendation_id = %s",
|
|
266
|
+
(recommendation_id,),
|
|
267
|
+
)
|
|
268
|
+
row = cur.fetchone()
|
|
269
|
+
if row is None:
|
|
270
|
+
return None
|
|
271
|
+
created_at, payload, row_org = float(row[0]), str(row[1]), str(row[2])
|
|
272
|
+
if time.time() - created_at > self._ttl:
|
|
273
|
+
with self._cursor(self._url) as cur:
|
|
274
|
+
cur.execute(
|
|
275
|
+
"DELETE FROM recommendations WHERE recommendation_id = %s",
|
|
276
|
+
(recommendation_id,),
|
|
277
|
+
)
|
|
278
|
+
return None
|
|
279
|
+
if org_id is not None and row_org != org_id:
|
|
280
|
+
return None
|
|
281
|
+
return _deserialize(recommendation_id, payload, created_at, row_org)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class RedisRecommendationStore:
|
|
285
|
+
"""Recommendation store backed by Redis (Cloud Memorystore).
|
|
286
|
+
|
|
287
|
+
Each recommendation is stored as a Redis hash at key ``rec:{recommendation_id}``
|
|
288
|
+
with a wall-clock TTL. Org isolation is enforced by checking the ``org_id`` field
|
|
289
|
+
on every get.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
def __init__(self, redis_url: str, ttl_seconds: int = 86_400):
|
|
293
|
+
from minima.recommender._redis_client import get_client
|
|
294
|
+
|
|
295
|
+
self._ttl = ttl_seconds
|
|
296
|
+
self._r = get_client(redis_url)
|
|
297
|
+
|
|
298
|
+
def _key(self, recommendation_id: str) -> str:
|
|
299
|
+
return f"rec:{recommendation_id}"
|
|
300
|
+
|
|
301
|
+
def put(self, rec: StoredRecommendation) -> None:
|
|
302
|
+
import time
|
|
303
|
+
|
|
304
|
+
created = time.time() if rec.created_at == 0.0 else rec.created_at
|
|
305
|
+
key = self._key(rec.recommendation_id)
|
|
306
|
+
self._r.hset(
|
|
307
|
+
key,
|
|
308
|
+
mapping={
|
|
309
|
+
"payload": _serialize(rec),
|
|
310
|
+
"created_at": str(created),
|
|
311
|
+
"org_id": rec.org_id,
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
self._r.expire(key, self._ttl)
|
|
315
|
+
|
|
316
|
+
def get(self, recommendation_id: str, org_id: str | None = None) -> StoredRecommendation | None:
|
|
317
|
+
import time
|
|
318
|
+
|
|
319
|
+
key = self._key(recommendation_id)
|
|
320
|
+
row = self._r.hgetall(key)
|
|
321
|
+
if not row:
|
|
322
|
+
return None
|
|
323
|
+
created_at = float(row["created_at"])
|
|
324
|
+
if time.time() - created_at > self._ttl:
|
|
325
|
+
self._r.delete(key)
|
|
326
|
+
return None
|
|
327
|
+
row_org = row.get("org_id", "default")
|
|
328
|
+
if org_id is not None and row_org != org_id:
|
|
329
|
+
return None
|
|
330
|
+
from minima.recommender._redis_client import decode
|
|
331
|
+
|
|
332
|
+
return _deserialize(recommendation_id, decode(row["payload"]), created_at, decode(row_org))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def build_recstore(settings: Settings) -> RecStore:
|
|
336
|
+
# MINIMA_RECSTORE_BACKEND overrides the backend for RecStore + DurableRefs only.
|
|
337
|
+
# Falls back to MINIMA_RECOMMENDATION_STORE when not set.
|
|
338
|
+
backend = (
|
|
339
|
+
settings.minima_recstore_backend.strip().lower()
|
|
340
|
+
or settings.minima_recommendation_store.strip().lower()
|
|
341
|
+
)
|
|
342
|
+
ttl = settings.minima_recommendation_ttl_seconds
|
|
343
|
+
if backend == "redis":
|
|
344
|
+
if not settings.minima_redis_url:
|
|
345
|
+
raise RuntimeError("MINIMA_REDIS_URL is required when MINIMA_RECSTORE_BACKEND=redis")
|
|
346
|
+
return RedisRecommendationStore(settings.minima_redis_url, ttl)
|
|
347
|
+
if backend in ("cloudsql", "postgres", "postgresql"):
|
|
348
|
+
if not settings.minima_database_url:
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
"MINIMA_DATABASE_URL is required when MINIMA_RECOMMENDATION_STORE=cloudsql"
|
|
351
|
+
)
|
|
352
|
+
return PostgresRecommendationStore(settings.minima_database_url, ttl)
|
|
353
|
+
if backend == "sqlite":
|
|
354
|
+
return SqliteRecommendationStore(settings.minima_sqlite_path, ttl)
|
|
355
|
+
return RecommendationStore(ttl)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class LaneCounter:
|
|
359
|
+
"""Per-lane feedback counter used to trigger reflection on a cadence."""
|
|
360
|
+
|
|
361
|
+
def __init__(self) -> None:
|
|
362
|
+
self._counts: dict[str, int] = {}
|
|
363
|
+
self._lock = Lock()
|
|
364
|
+
|
|
365
|
+
def bump(self, lane: str) -> int:
|
|
366
|
+
with self._lock:
|
|
367
|
+
self._counts[lane] = self._counts.get(lane, 0) + 1
|
|
368
|
+
return self._counts[lane]
|