superlocalmemory 3.4.19 → 3.4.22
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.
- package/CHANGELOG.md +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +4 -3
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +254 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +423 -0
- package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
- package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
- package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
- package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
- package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
- package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory v3.4.22 — LLD-04 §4.1 + §3
|
|
4
|
+
|
|
5
|
+
"""``/api/v3/brain`` — unified Brain endpoint (LLD-04 v2).
|
|
6
|
+
|
|
7
|
+
Merges the pre-3.4.22 Patterns / Learning / Behavioral dashboard tabs
|
|
8
|
+
into one auth-gated, honestly-labelled JSON payload. Every metric has
|
|
9
|
+
an ``is_real`` (numeric counters) or ``is_real_ml`` (ML-derived) flag
|
|
10
|
+
and points at the real table it was computed from. No metric is
|
|
11
|
+
fabricated — if a source does not exist we return zero and flag it.
|
|
12
|
+
|
|
13
|
+
Security primitives consumed from LLD-07 §6:
|
|
14
|
+
|
|
15
|
+
* ``verify_install_token`` — constant-time token check for auth gate.
|
|
16
|
+
* ``redact_secrets`` — regex+entropy secret scrub.
|
|
17
|
+
|
|
18
|
+
Deprecated shims (1 release grace) live below the main route. They
|
|
19
|
+
return the historical-ish shape plus ``deprecated: true`` and
|
|
20
|
+
``use_instead: /api/v3/brain``, and require the same auth.
|
|
21
|
+
|
|
22
|
+
Design notes (LLD-04 §7 hard rules):
|
|
23
|
+
|
|
24
|
+
* **U2** — every metric section carries ``is_real`` or ``is_real_ml``.
|
|
25
|
+
* **U3** — ``learning.phase`` never returns ``3`` without an active
|
|
26
|
+
model row AND passing SHA check. Both conditions computed here.
|
|
27
|
+
* **U4** — response shape must not contain the three pre-3.4.22
|
|
28
|
+
fabricated metrics (24h hit-rate, avg age on hit, skill-evolution
|
|
29
|
+
row counts). A static test greps this file to verify; therefore we
|
|
30
|
+
never spell those names anywhere in this module.
|
|
31
|
+
* **U6** — install token required on ``/api/v3/brain`` and all
|
|
32
|
+
deprecated shim routes. See ``require_install_token`` below.
|
|
33
|
+
* **U10** — feature count surfaced from ``features.FEATURE_DIM``;
|
|
34
|
+
stratum total from module constant ``_STRATA_TOTAL`` (48 = 4×3×4).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import sqlite3
|
|
42
|
+
from datetime import datetime, timedelta, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
47
|
+
|
|
48
|
+
from superlocalmemory.core.security_primitives import (
|
|
49
|
+
redact_secrets,
|
|
50
|
+
verify_install_token,
|
|
51
|
+
)
|
|
52
|
+
from superlocalmemory.learning.database import LearningDatabase
|
|
53
|
+
from superlocalmemory.learning.features import FEATURE_DIM
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger("superlocalmemory.routes.brain")
|
|
56
|
+
|
|
57
|
+
router = APIRouter(prefix="/api/v3", tags=["brain"])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Constants — surfaced via the response so the UI never hard-codes.
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# LLD-03 v2 stratum space = 4 query types × 3 entity bins × 4 time buckets.
|
|
65
|
+
_STRATA_TOTAL: int = 48
|
|
66
|
+
|
|
67
|
+
_VERSION: str = "3.4.22"
|
|
68
|
+
|
|
69
|
+
# Banned metric names (LLD-04 U4). Kept as a tuple for grep visibility;
|
|
70
|
+
# the source-level test asserts we don't accidentally reintroduce them.
|
|
71
|
+
# NOTE: do NOT add the literal forbidden-key strings here — the U4 grep
|
|
72
|
+
# guard runs over this file.
|
|
73
|
+
|
|
74
|
+
# Memory directory (home-dir based). Always resolved at call time so that
|
|
75
|
+
# tests can override via monkeypatch on ``_learning_db_path``.
|
|
76
|
+
_MEMORY_DIR_DEFAULT = Path.home() / ".superlocalmemory"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _learning_db_path() -> Path:
|
|
80
|
+
"""Return the path to the learning SQLite DB.
|
|
81
|
+
|
|
82
|
+
Separated as a function (not a module constant) so tests can
|
|
83
|
+
monkeypatch without touching Path.home. See
|
|
84
|
+
``tests/test_api/test_brain_endpoint.py``.
|
|
85
|
+
"""
|
|
86
|
+
return _MEMORY_DIR_DEFAULT / "learning.db"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _memory_db_path() -> Path:
|
|
90
|
+
return _MEMORY_DIR_DEFAULT / "memory.db"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Auth dependency (LLD-04 §4.1, LLD-07 §6.6)
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def require_install_token(request: Request) -> None:
|
|
99
|
+
"""FastAPI dependency: enforces install-token on Brain routes.
|
|
100
|
+
|
|
101
|
+
Accepts either of:
|
|
102
|
+
|
|
103
|
+
X-Install-Token: <token>
|
|
104
|
+
Authorization: Bearer <token>
|
|
105
|
+
|
|
106
|
+
Token comparison uses ``verify_install_token`` which wraps
|
|
107
|
+
``hmac.compare_digest`` — constant time regardless of input.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
HTTPException(401) with ``WWW-Authenticate: Install-Token`` so
|
|
111
|
+
clients know how to retry. Never leaks whether the token file
|
|
112
|
+
exists, whether the header was missing, or which comparison
|
|
113
|
+
branch rejected the value.
|
|
114
|
+
"""
|
|
115
|
+
header_token = request.headers.get("x-install-token")
|
|
116
|
+
presented: str | None = header_token
|
|
117
|
+
if not presented:
|
|
118
|
+
auth = request.headers.get("authorization", "")
|
|
119
|
+
if auth:
|
|
120
|
+
# Strip scheme; be tolerant of casing. Empty-after-strip → None.
|
|
121
|
+
scheme, _, value = auth.partition(" ")
|
|
122
|
+
if scheme.lower() == "bearer" and value.strip():
|
|
123
|
+
presented = value.strip()
|
|
124
|
+
if not presented or not verify_install_token(presented):
|
|
125
|
+
raise HTTPException(
|
|
126
|
+
status_code=401,
|
|
127
|
+
detail="install_token_required",
|
|
128
|
+
headers={"WWW-Authenticate": "Install-Token"},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Secret-redacted preferences (LLD-04 §4.1, LLD-07 §6.3)
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def redact_secrets_in_preferences(prefs: dict) -> dict:
|
|
138
|
+
"""Deep-copy ``prefs``, scrub every string through ``redact_secrets``,
|
|
139
|
+
and surface the count of substitutions as ``redacted_count``.
|
|
140
|
+
|
|
141
|
+
The input is never mutated — we build a new structure and preserve
|
|
142
|
+
list/dict shapes exactly. Non-string scalars (ints, floats, bools,
|
|
143
|
+
None) pass through untouched. Unknown container types are left as-is
|
|
144
|
+
(best-effort forward compatibility).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
New dict with the same shape plus ``redacted_count`` at the top
|
|
148
|
+
level. If ``prefs`` is not a dict, returns
|
|
149
|
+
``{"redacted_count": 0}`` (defensive).
|
|
150
|
+
"""
|
|
151
|
+
if not isinstance(prefs, dict):
|
|
152
|
+
return {"redacted_count": 0}
|
|
153
|
+
|
|
154
|
+
counter = [0] # mutable holder so the nested closure can increment.
|
|
155
|
+
|
|
156
|
+
def _scrub(value: Any) -> Any:
|
|
157
|
+
if isinstance(value, str):
|
|
158
|
+
scrubbed = redact_secrets(value)
|
|
159
|
+
if scrubbed != value:
|
|
160
|
+
counter[0] += 1
|
|
161
|
+
return scrubbed
|
|
162
|
+
if isinstance(value, dict):
|
|
163
|
+
return {k: _scrub(v) for k, v in value.items()}
|
|
164
|
+
if isinstance(value, list):
|
|
165
|
+
return [_scrub(v) for v in value]
|
|
166
|
+
if isinstance(value, tuple):
|
|
167
|
+
return tuple(_scrub(v) for v in value)
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
out: dict = {k: _scrub(v) for k, v in prefs.items()}
|
|
171
|
+
out["redacted_count"] = counter[0]
|
|
172
|
+
out.setdefault("is_real", True)
|
|
173
|
+
out.setdefault("source", prefs.get("source", "_store_patterns"))
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Section builders — each SELECTs from a real table. If a table is empty,
|
|
179
|
+
# returns 0 with ``is_real: true`` (honest). Never invents a number.
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _load_raw_preferences(profile_id: str) -> dict:
|
|
184
|
+
"""Load preference categories from the behavioral pattern store.
|
|
185
|
+
|
|
186
|
+
Returns a dict shaped like::
|
|
187
|
+
|
|
188
|
+
{"topics": [...], "entities": [...], "tech": [...], "source": ...}
|
|
189
|
+
|
|
190
|
+
with empty lists if the store / DB is not available. Overridden in
|
|
191
|
+
tests via monkeypatch to keep the Brain route decoupled from the
|
|
192
|
+
full behavioral pipeline.
|
|
193
|
+
"""
|
|
194
|
+
out: dict[str, Any] = {
|
|
195
|
+
"topics": [], "entities": [], "tech": [],
|
|
196
|
+
"source": "_store_patterns",
|
|
197
|
+
}
|
|
198
|
+
db_path = _learning_db_path()
|
|
199
|
+
if not db_path.exists():
|
|
200
|
+
return out
|
|
201
|
+
try:
|
|
202
|
+
from superlocalmemory.learning.behavioral import BehavioralPatternStore
|
|
203
|
+
store = BehavioralPatternStore(str(db_path))
|
|
204
|
+
patterns = store.get_patterns(profile_id=profile_id) or []
|
|
205
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
206
|
+
logger.debug("load_raw_preferences: %s", exc)
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
topics: list[dict[str, Any]] = []
|
|
210
|
+
entities: list[dict[str, Any]] = []
|
|
211
|
+
tech: list[dict[str, Any]] = []
|
|
212
|
+
for p in patterns:
|
|
213
|
+
ptype = p.get("pattern_type") or ""
|
|
214
|
+
meta = p.get("metadata") or {}
|
|
215
|
+
name = meta.get("value") or p.get("pattern_key") or ""
|
|
216
|
+
if not name:
|
|
217
|
+
continue
|
|
218
|
+
if ptype == "tech_preference":
|
|
219
|
+
tech.append({
|
|
220
|
+
"name": str(name),
|
|
221
|
+
"frequency": float(p.get("confidence", 0.0) or 0.0),
|
|
222
|
+
})
|
|
223
|
+
elif ptype in ("entity", "interest"):
|
|
224
|
+
entities.append({
|
|
225
|
+
"name": str(name),
|
|
226
|
+
"mention_count": int(p.get("evidence_count", 0) or 0),
|
|
227
|
+
})
|
|
228
|
+
elif ptype == "topic":
|
|
229
|
+
topics.append({
|
|
230
|
+
"name": str(name),
|
|
231
|
+
"strength": float(p.get("confidence", 0.0) or 0.0),
|
|
232
|
+
})
|
|
233
|
+
out["topics"] = topics
|
|
234
|
+
out["entities"] = entities
|
|
235
|
+
out["tech"] = tech
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _compute_preferences(profile_id: str) -> dict:
|
|
240
|
+
raw = _load_raw_preferences(profile_id)
|
|
241
|
+
return redact_secrets_in_preferences(raw)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _compute_learning_status(profile_id: str,
|
|
245
|
+
lrn_db: LearningDatabase) -> dict:
|
|
246
|
+
"""Section ``learning`` — LLD-02 §4.10 phase truth + counters."""
|
|
247
|
+
signals_total = _safe_count(lrn_db, "learning_signals", profile_id)
|
|
248
|
+
features_total = _safe_count(lrn_db, "learning_features", profile_id)
|
|
249
|
+
# Raw count of historic pre-v3.4.22 feedback rows (the source table)
|
|
250
|
+
legacy_feedback_rows = _safe_count(lrn_db, "learning_feedback", profile_id)
|
|
251
|
+
# Count of rows actually copied forward into learning_signals by
|
|
252
|
+
# legacy_migration.migrate_legacy_feedback (signal_type='legacy_feedback').
|
|
253
|
+
# The difference (raw - migrated) is what the dashboard's
|
|
254
|
+
# "Migrate legacy data" card surfaces as pending work.
|
|
255
|
+
legacy_migrated = _count_legacy_migrated(lrn_db, profile_id)
|
|
256
|
+
signals_last_hour = _count_signals_since(
|
|
257
|
+
lrn_db, profile_id,
|
|
258
|
+
datetime.now(timezone.utc) - timedelta(hours=1),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
active = lrn_db.load_active_model(profile_id)
|
|
262
|
+
model_active = active is not None
|
|
263
|
+
model_version: str | None = None
|
|
264
|
+
model_trained_at: str | None = None
|
|
265
|
+
model_sha256_present = False
|
|
266
|
+
if model_active and isinstance(active, dict):
|
|
267
|
+
model_version = active.get("model_version")
|
|
268
|
+
model_trained_at = active.get("trained_at")
|
|
269
|
+
model_sha256_present = bool(active.get("bytes_sha256"))
|
|
270
|
+
|
|
271
|
+
phase, phase_label = _resolve_phase(signals_total, model_active,
|
|
272
|
+
model_sha256_present)
|
|
273
|
+
|
|
274
|
+
# S9-DASH-03: consult migration_log. If the legacy migration is
|
|
275
|
+
# marked complete but some rows remain uncopied, those rows are
|
|
276
|
+
# structurally un-migratable (malformed, duplicate, or missing
|
|
277
|
+
# required cols). Reporting them as "pending" is misleading and
|
|
278
|
+
# the dashboard card nags forever. After completion we report
|
|
279
|
+
# pending=0 so the card auto-hides.
|
|
280
|
+
migration_complete = _legacy_migration_sentinel_complete(lrn_db)
|
|
281
|
+
legacy_pending = max(0, legacy_feedback_rows - legacy_migrated)
|
|
282
|
+
if migration_complete:
|
|
283
|
+
legacy_pending = 0
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
"phase": phase,
|
|
287
|
+
"phase_label": phase_label,
|
|
288
|
+
"signals_total": signals_total,
|
|
289
|
+
"signals_last_hour": signals_last_hour,
|
|
290
|
+
"features_total": features_total,
|
|
291
|
+
"feature_count_expected": FEATURE_DIM,
|
|
292
|
+
# Honest split (v3.4.22+): raw historic table count vs rows
|
|
293
|
+
# actually copied forward via the migrate-legacy flow.
|
|
294
|
+
"legacy_feedback_rows": legacy_feedback_rows,
|
|
295
|
+
"legacy_migrated_count": legacy_migrated,
|
|
296
|
+
"legacy_migration_pending": legacy_pending,
|
|
297
|
+
"legacy_migration_complete": migration_complete,
|
|
298
|
+
"model_active": model_active,
|
|
299
|
+
"model_version": model_version,
|
|
300
|
+
"model_trained_at": model_trained_at,
|
|
301
|
+
"model_sha256_present": model_sha256_present,
|
|
302
|
+
"is_real": True,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _resolve_phase(signals: int, model_active: bool,
|
|
307
|
+
sha_present: bool) -> tuple[int, str]:
|
|
308
|
+
"""LLD-02 §4.10 — phase truth, U3 enforcement.
|
|
309
|
+
|
|
310
|
+
Phase 3 requires BOTH ``model_active`` and a non-empty SHA value on
|
|
311
|
+
the active row. Missing either falls back to phase 2 (≥50 signals)
|
|
312
|
+
or phase 1 (cold).
|
|
313
|
+
"""
|
|
314
|
+
if model_active and sha_present and signals >= 200:
|
|
315
|
+
return 3, "LightGBM ranker active"
|
|
316
|
+
if signals >= 50:
|
|
317
|
+
return 2, "Contextual bandit"
|
|
318
|
+
return 1, "Cold start (cross-encoder only)"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _compute_usage_stats(profile_id: str) -> dict:
|
|
322
|
+
"""Section ``usage`` — real counters from ``learning_signals``.
|
|
323
|
+
|
|
324
|
+
All three values are honest aggregations of the last 24 h of signals.
|
|
325
|
+
``is_real_ml`` stays False because these are counters, not ML output,
|
|
326
|
+
but the numbers themselves are pinned to real rows. LLD-04 U2 / §3.1.
|
|
327
|
+
"""
|
|
328
|
+
lrn_db = _lazy_db_for(profile_id)
|
|
329
|
+
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
330
|
+
recalls_24h = _count_signals_since(
|
|
331
|
+
lrn_db, profile_id, since,
|
|
332
|
+
signal_types=("recall_hit", "recall", "recall_miss",
|
|
333
|
+
"candidate", "shown"),
|
|
334
|
+
)
|
|
335
|
+
return {
|
|
336
|
+
"recalls_last_24h": recalls_24h,
|
|
337
|
+
"top_query_types": _top_query_types(lrn_db, profile_id, since),
|
|
338
|
+
"top_time_buckets": _top_time_buckets(lrn_db, profile_id, since),
|
|
339
|
+
"source": "learning_signals_24h_aggregation",
|
|
340
|
+
"is_real_ml": False,
|
|
341
|
+
"disclaimer": "Statistical counters, not ML output.",
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _top_query_types(
|
|
346
|
+
lrn_db: LearningDatabase,
|
|
347
|
+
profile_id: str,
|
|
348
|
+
since: datetime,
|
|
349
|
+
*,
|
|
350
|
+
limit: int = 5,
|
|
351
|
+
) -> list[dict]:
|
|
352
|
+
"""Return top-N query types from last-24h signals with percentages.
|
|
353
|
+
|
|
354
|
+
Query type lives in ``learning_features.features_json`` as one-hot
|
|
355
|
+
``query_type_sh`` / ``query_type_mh`` / ``query_type_temp`` /
|
|
356
|
+
``query_type_od`` (features.py FEATURE_NAMES). We aggregate by the
|
|
357
|
+
active one-hot slot. Missing table or zero rows → empty list.
|
|
358
|
+
"""
|
|
359
|
+
# E.1 (v3.4.22 perf): push the query-type classification into SQL via
|
|
360
|
+
# json_extract so we don't drag 10k rows' worth of JSON through Python.
|
|
361
|
+
# SQLite's json1 extension treats json_extract as an ordinary scalar
|
|
362
|
+
# function, so the whole aggregation becomes a single COUNT(*)
|
|
363
|
+
# GROUP BY on a computed column. Falls back to the Python path only
|
|
364
|
+
# if json_extract is unavailable (very old SQLite builds).
|
|
365
|
+
counts: dict[str, int] = {
|
|
366
|
+
"single_hop": 0, "multi_hop": 0, "temporal": 0, "open_domain": 0,
|
|
367
|
+
}
|
|
368
|
+
try:
|
|
369
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
370
|
+
conn.row_factory = sqlite3.Row
|
|
371
|
+
try:
|
|
372
|
+
agg_rows = conn.execute(
|
|
373
|
+
"SELECT "
|
|
374
|
+
" CASE "
|
|
375
|
+
" WHEN CAST(json_extract(f.features_json, '$.query_type_sh') AS REAL) >= 0.5 THEN 'single_hop' "
|
|
376
|
+
" WHEN CAST(json_extract(f.features_json, '$.query_type_mh') AS REAL) >= 0.5 THEN 'multi_hop' "
|
|
377
|
+
" WHEN CAST(json_extract(f.features_json, '$.query_type_temp') AS REAL) >= 0.5 THEN 'temporal' "
|
|
378
|
+
" WHEN CAST(json_extract(f.features_json, '$.query_type_od') AS REAL) >= 0.5 THEN 'open_domain' "
|
|
379
|
+
" ELSE NULL END AS qt, "
|
|
380
|
+
" COUNT(*) AS n "
|
|
381
|
+
"FROM learning_features f "
|
|
382
|
+
"JOIN learning_signals s ON s.id = f.signal_id "
|
|
383
|
+
"WHERE s.profile_id = ? AND s.created_at >= ? "
|
|
384
|
+
" AND f.is_synthetic = 0 "
|
|
385
|
+
"GROUP BY qt",
|
|
386
|
+
(profile_id, since.isoformat()),
|
|
387
|
+
).fetchall()
|
|
388
|
+
for row in agg_rows:
|
|
389
|
+
qt = row["qt"]
|
|
390
|
+
if qt in counts:
|
|
391
|
+
counts[qt] = int(row["n"] or 0)
|
|
392
|
+
finally:
|
|
393
|
+
conn.close()
|
|
394
|
+
except sqlite3.Error: # pragma: no cover — schema + json1 always present
|
|
395
|
+
return []
|
|
396
|
+
total = sum(counts.values())
|
|
397
|
+
if total == 0:
|
|
398
|
+
return []
|
|
399
|
+
ranked = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
|
|
400
|
+
return [
|
|
401
|
+
{"type": name, "pct": round(100.0 * n / total, 1)}
|
|
402
|
+
for name, n in ranked[:limit] if n > 0
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _top_time_buckets(
|
|
407
|
+
lrn_db: LearningDatabase,
|
|
408
|
+
profile_id: str,
|
|
409
|
+
since: datetime,
|
|
410
|
+
*,
|
|
411
|
+
limit: int = 3,
|
|
412
|
+
) -> list[dict]:
|
|
413
|
+
"""Return top-N hour buckets ("HH:00") from last-24h signals.
|
|
414
|
+
|
|
415
|
+
Parses ``created_at`` as ISO timestamp, buckets by hour-of-day, returns
|
|
416
|
+
top N with percentages. Honest empty when no rows.
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
420
|
+
conn.row_factory = sqlite3.Row
|
|
421
|
+
try:
|
|
422
|
+
rows = conn.execute(
|
|
423
|
+
"SELECT created_at FROM learning_signals "
|
|
424
|
+
"WHERE profile_id = ? AND created_at >= ? "
|
|
425
|
+
"LIMIT 10000",
|
|
426
|
+
(profile_id, since.isoformat()),
|
|
427
|
+
).fetchall()
|
|
428
|
+
finally:
|
|
429
|
+
conn.close()
|
|
430
|
+
except sqlite3.Error: # pragma: no cover
|
|
431
|
+
return []
|
|
432
|
+
buckets: dict[int, int] = {}
|
|
433
|
+
for r in rows:
|
|
434
|
+
ts = str(r["created_at"] or "")
|
|
435
|
+
if not ts:
|
|
436
|
+
continue
|
|
437
|
+
try:
|
|
438
|
+
# ISO: "YYYY-MM-DDTHH:MM:..." — take the "HH" slice.
|
|
439
|
+
hh = int(ts[11:13])
|
|
440
|
+
except (ValueError, IndexError): # pragma: no cover
|
|
441
|
+
continue
|
|
442
|
+
buckets[hh] = buckets.get(hh, 0) + 1
|
|
443
|
+
total = sum(buckets.values())
|
|
444
|
+
if total == 0:
|
|
445
|
+
return []
|
|
446
|
+
ranked = sorted(buckets.items(), key=lambda kv: kv[1], reverse=True)
|
|
447
|
+
return [
|
|
448
|
+
{"bucket": f"{hh:02d}:00",
|
|
449
|
+
"pct": round(100.0 * n / total, 1)}
|
|
450
|
+
for hh, n in ranked[:limit] if n > 0
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _compute_bandit_snapshot(profile_id: str,
|
|
455
|
+
lrn_db: LearningDatabase) -> dict:
|
|
456
|
+
"""Section ``bandit`` — derive summary from ``ContextualBandit.snapshot``.
|
|
457
|
+
|
|
458
|
+
We intentionally compute the *derived* fields here (strata_active,
|
|
459
|
+
top_arm_global, unsettled_plays) rather than asking the bandit to
|
|
460
|
+
produce them — this keeps the stratum total constant surfaced from
|
|
461
|
+
one authoritative place (``_STRATA_TOTAL``) instead of duplicating.
|
|
462
|
+
"""
|
|
463
|
+
strata_active = 0
|
|
464
|
+
top_arm_global: dict | None = None
|
|
465
|
+
unsettled_plays = 0
|
|
466
|
+
oldest_unsettled_seconds: int | None = None
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
from superlocalmemory.learning.bandit import ContextualBandit
|
|
470
|
+
bandit = ContextualBandit(
|
|
471
|
+
db_path=lrn_db.path, # shared learning DB
|
|
472
|
+
profile_id=profile_id,
|
|
473
|
+
)
|
|
474
|
+
snap = bandit.snapshot() or {}
|
|
475
|
+
strata_active = sum(1 for arms in snap.values() if arms)
|
|
476
|
+
# Global top arm by plays across all strata.
|
|
477
|
+
best: tuple[str, int] | None = None
|
|
478
|
+
for stratum_id, arms in snap.items():
|
|
479
|
+
for arm in arms:
|
|
480
|
+
plays = int(arm.get("plays", 0) or 0)
|
|
481
|
+
if best is None or plays > best[1]:
|
|
482
|
+
best = (arm["arm_id"], plays)
|
|
483
|
+
if best is not None:
|
|
484
|
+
top_arm_global = {"arm_id": best[0], "plays": best[1]}
|
|
485
|
+
unsettled_plays, oldest_unsettled_seconds = _bandit_unsettled(
|
|
486
|
+
lrn_db.path, profile_id,
|
|
487
|
+
)
|
|
488
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
489
|
+
logger.debug("bandit snapshot: %s", exc)
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
"strata_active": strata_active,
|
|
493
|
+
"strata_total": _STRATA_TOTAL,
|
|
494
|
+
"top_arm_global": top_arm_global,
|
|
495
|
+
"unsettled_plays": unsettled_plays,
|
|
496
|
+
"oldest_unsettled_seconds": oldest_unsettled_seconds,
|
|
497
|
+
"is_real": True,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _bandit_unsettled(db_path: str, profile_id: str) -> tuple[int, int | None]:
|
|
502
|
+
"""Count unsettled bandit_plays rows + age (sec) of oldest one."""
|
|
503
|
+
try:
|
|
504
|
+
conn = sqlite3.connect(db_path, timeout=5.0)
|
|
505
|
+
conn.row_factory = sqlite3.Row
|
|
506
|
+
try:
|
|
507
|
+
row = conn.execute(
|
|
508
|
+
"SELECT COUNT(*) AS cnt, MIN(played_at) AS oldest "
|
|
509
|
+
"FROM bandit_plays "
|
|
510
|
+
"WHERE profile_id = ? AND settled_at IS NULL",
|
|
511
|
+
(profile_id,),
|
|
512
|
+
).fetchone()
|
|
513
|
+
finally:
|
|
514
|
+
conn.close()
|
|
515
|
+
except sqlite3.Error:
|
|
516
|
+
return 0, None
|
|
517
|
+
if row is None: # pragma: no cover — COUNT() always yields a row
|
|
518
|
+
return 0, None
|
|
519
|
+
cnt = int(row["cnt"] or 0)
|
|
520
|
+
oldest_iso = row["oldest"]
|
|
521
|
+
if not oldest_iso:
|
|
522
|
+
return cnt, None
|
|
523
|
+
try:
|
|
524
|
+
played_at = datetime.fromisoformat(oldest_iso)
|
|
525
|
+
if played_at.tzinfo is None:
|
|
526
|
+
played_at = played_at.replace(tzinfo=timezone.utc)
|
|
527
|
+
age = (datetime.now(timezone.utc) - played_at).total_seconds()
|
|
528
|
+
return cnt, int(max(age, 0))
|
|
529
|
+
except (ValueError, TypeError):
|
|
530
|
+
return cnt, None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _compute_cache_stats() -> dict:
|
|
534
|
+
"""Section ``cache`` — DB file size + row count (if accessible)."""
|
|
535
|
+
db = _memory_db_path()
|
|
536
|
+
if not db.exists():
|
|
537
|
+
return {"db_size_bytes": 0, "entry_count": 0, "is_real": True}
|
|
538
|
+
size = db.stat().st_size
|
|
539
|
+
entry_count = 0
|
|
540
|
+
try:
|
|
541
|
+
conn = sqlite3.connect(str(db), timeout=5.0)
|
|
542
|
+
try:
|
|
543
|
+
row = conn.execute(
|
|
544
|
+
"SELECT COUNT(*) AS cnt FROM atomic_facts",
|
|
545
|
+
).fetchone()
|
|
546
|
+
entry_count = int(row[0]) if row else 0
|
|
547
|
+
finally:
|
|
548
|
+
conn.close()
|
|
549
|
+
except sqlite3.Error:
|
|
550
|
+
entry_count = 0
|
|
551
|
+
return {
|
|
552
|
+
"db_size_bytes": size,
|
|
553
|
+
"entry_count": entry_count,
|
|
554
|
+
"is_real": True,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _adapter_last_sync_ago(adapter_name: str) -> int | None:
|
|
559
|
+
"""Seconds since adapter's most recent successful sync.
|
|
560
|
+
|
|
561
|
+
Reads from ``cross_platform_sync_log`` (LLD-07 M004) in ``memory.db``.
|
|
562
|
+
Returns ``None`` when the log is absent or has no successful row —
|
|
563
|
+
honest empty rather than a fabricated number.
|
|
564
|
+
"""
|
|
565
|
+
try:
|
|
566
|
+
import sqlite3 as _sqlite3
|
|
567
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
568
|
+
from pathlib import Path as _P
|
|
569
|
+
|
|
570
|
+
memory_db = _P.home() / ".superlocalmemory" / "memory.db"
|
|
571
|
+
if not memory_db.exists():
|
|
572
|
+
return None
|
|
573
|
+
conn = _sqlite3.connect(
|
|
574
|
+
f"file:{memory_db}?mode=ro", uri=True, timeout=1.0,
|
|
575
|
+
)
|
|
576
|
+
try:
|
|
577
|
+
cur = conn.execute(
|
|
578
|
+
"SELECT last_sync_at FROM cross_platform_sync_log "
|
|
579
|
+
"WHERE adapter_name=? AND success=1 "
|
|
580
|
+
"ORDER BY last_sync_at DESC LIMIT 1",
|
|
581
|
+
(adapter_name,),
|
|
582
|
+
)
|
|
583
|
+
row = cur.fetchone()
|
|
584
|
+
finally:
|
|
585
|
+
conn.close()
|
|
586
|
+
if row is None or row[0] is None:
|
|
587
|
+
return None
|
|
588
|
+
last = _dt.fromisoformat(str(row[0]).replace("Z", "+00:00"))
|
|
589
|
+
delta = (_dt.now(_tz.utc) - last).total_seconds()
|
|
590
|
+
return max(0, int(delta))
|
|
591
|
+
except Exception: # pragma: no cover — defensive
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _compute_cross_platform() -> dict:
|
|
596
|
+
"""Section ``cross_platform`` — live status per injection target.
|
|
597
|
+
|
|
598
|
+
Each adapter's ``is_active()`` is cheap (env + ``Path.is_dir()``) per
|
|
599
|
+
LLD-05 A10. Last-sync-at is read from ``cross_platform_sync_log`` in
|
|
600
|
+
``memory.db`` (LLD-07 M004). On any adapter error, that adapter
|
|
601
|
+
reports ``active: false`` with ``reason: error:<ExcName>`` rather
|
|
602
|
+
than crashing the whole Brain endpoint (LLD-04 §2 — "honest, never
|
|
603
|
+
fake"). An unimportable adapter means the install is missing Wave 2C
|
|
604
|
+
components, which is legitimate for an older 3.4.20 → 3.4.22 upgrade
|
|
605
|
+
mid-migration.
|
|
606
|
+
"""
|
|
607
|
+
out: dict = {}
|
|
608
|
+
# S8-SEC-06 fix: use the canonical factory instead of constructing
|
|
609
|
+
# adapters with no kwargs (all three require scope/base_dir/sync_log_db/
|
|
610
|
+
# recall_fn). ``build_default_adapters`` returns fully-wired instances
|
|
611
|
+
# matching what the background sync loop uses, so the panel reflects
|
|
612
|
+
# the same truth the daemon acts on.
|
|
613
|
+
try:
|
|
614
|
+
from superlocalmemory.cli.context_commands import (
|
|
615
|
+
build_default_adapters as _build_adapters,
|
|
616
|
+
)
|
|
617
|
+
_adapters = _build_adapters()
|
|
618
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
619
|
+
_adapters = []
|
|
620
|
+
logger.debug("brain: adapter factory failed: %s", exc)
|
|
621
|
+
|
|
622
|
+
# Summarise by adapter kind: cursor (project+global), antigravity
|
|
623
|
+
# (workspace+global), copilot. ``adapter.name`` disambiguates scope.
|
|
624
|
+
_seen: set[str] = set()
|
|
625
|
+
for a in _adapters:
|
|
626
|
+
name_attr = getattr(a, "name", "") or ""
|
|
627
|
+
cls = type(a).__name__
|
|
628
|
+
kind = None
|
|
629
|
+
if "Cursor" in cls:
|
|
630
|
+
kind = "cursor"
|
|
631
|
+
elif "Antigravity" in cls:
|
|
632
|
+
kind = "antigravity"
|
|
633
|
+
elif "Copilot" in cls:
|
|
634
|
+
kind = "copilot"
|
|
635
|
+
if kind is None:
|
|
636
|
+
# Unknown adapter class — skip to avoid polluting the JSON
|
|
637
|
+
# with an ``out[None]`` key. (S10-SEC-N-01 fix.)
|
|
638
|
+
continue
|
|
639
|
+
if kind in _seen:
|
|
640
|
+
# For cursor/antigravity, which have project+global, the first
|
|
641
|
+
# active one wins; this surface is a health indicator, not a
|
|
642
|
+
# per-scope breakdown. (Dev view can drill in later.)
|
|
643
|
+
continue
|
|
644
|
+
try:
|
|
645
|
+
is_active = bool(a.is_active())
|
|
646
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
647
|
+
out[kind] = {"active": False,
|
|
648
|
+
"reason": f"error:{exc.__class__.__name__}"}
|
|
649
|
+
_seen.add(kind)
|
|
650
|
+
continue
|
|
651
|
+
out[kind] = {
|
|
652
|
+
"active": is_active,
|
|
653
|
+
"last_sync_seconds_ago": _adapter_last_sync_ago(name_attr or kind),
|
|
654
|
+
}
|
|
655
|
+
_seen.add(kind)
|
|
656
|
+
# Fill in missing slots so the shape stays stable.
|
|
657
|
+
for kind in ("cursor", "antigravity", "copilot"):
|
|
658
|
+
out.setdefault(kind, {"active": False, "reason": "adapter_unavailable"})
|
|
659
|
+
# Claude Code hook proxy: active once the install-token exists
|
|
660
|
+
# (required for prewarm per LLD-01 §4.4 R14).
|
|
661
|
+
try:
|
|
662
|
+
from superlocalmemory.core import security_primitives as _sp
|
|
663
|
+
out["claude_code"] = {
|
|
664
|
+
"active": _sp._install_token_path().exists(),
|
|
665
|
+
"hook": "UserPromptSubmit",
|
|
666
|
+
}
|
|
667
|
+
except Exception as exc: # pragma: no cover
|
|
668
|
+
out["claude_code"] = {"active": False,
|
|
669
|
+
"reason": f"error:{exc.__class__.__name__}"}
|
|
670
|
+
# MCP tool is registered by the unified daemon; if this route is
|
|
671
|
+
# reachable, the MCP server is up.
|
|
672
|
+
out["mcp"] = {"active": True, "tool": "mcp__slm"}
|
|
673
|
+
# CLI is trivially active on any install.
|
|
674
|
+
out["cli"] = {"active": True}
|
|
675
|
+
return out
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _meta_now() -> dict:
|
|
679
|
+
return {
|
|
680
|
+
"generated_at": datetime.now(timezone.utc)
|
|
681
|
+
.replace(microsecond=0).isoformat()
|
|
682
|
+
.replace("+00:00", "Z"),
|
|
683
|
+
"honest_labels": True,
|
|
684
|
+
"version": _VERSION,
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ---------------------------------------------------------------------------
|
|
689
|
+
# SQL helpers
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _safe_count(lrn_db: LearningDatabase, table: str,
|
|
694
|
+
profile_id: str) -> int:
|
|
695
|
+
"""``COUNT(*)`` from ``table`` where ``profile_id`` matches.
|
|
696
|
+
|
|
697
|
+
Returns 0 on any error (missing table, DB lock) — never raises
|
|
698
|
+
from the Brain endpoint.
|
|
699
|
+
"""
|
|
700
|
+
try:
|
|
701
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
702
|
+
conn.row_factory = sqlite3.Row
|
|
703
|
+
try:
|
|
704
|
+
row = conn.execute(
|
|
705
|
+
f"SELECT COUNT(*) AS cnt FROM {table} WHERE profile_id = ?", # nosec - table name is hard-coded above
|
|
706
|
+
(profile_id,),
|
|
707
|
+
).fetchone()
|
|
708
|
+
return int(row["cnt"]) if row else 0
|
|
709
|
+
finally:
|
|
710
|
+
conn.close()
|
|
711
|
+
except sqlite3.Error:
|
|
712
|
+
return 0
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _legacy_migration_sentinel_complete(lrn_db: LearningDatabase) -> bool:
|
|
716
|
+
"""Return True when ``migration_log`` marks the legacy feedback
|
|
717
|
+
migration as complete (S9-DASH-03).
|
|
718
|
+
|
|
719
|
+
Signals that the historic-data card should auto-hide even if a
|
|
720
|
+
small residual of un-migratable rows lingers in ``learning_feedback``.
|
|
721
|
+
"""
|
|
722
|
+
try:
|
|
723
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
724
|
+
try:
|
|
725
|
+
row = conn.execute(
|
|
726
|
+
"SELECT status FROM migration_log "
|
|
727
|
+
"WHERE name = 'LEG001_feedback_to_signals'",
|
|
728
|
+
).fetchone()
|
|
729
|
+
if row is None:
|
|
730
|
+
return False
|
|
731
|
+
return str(row[0]).lower() == "complete"
|
|
732
|
+
finally:
|
|
733
|
+
conn.close()
|
|
734
|
+
except sqlite3.Error:
|
|
735
|
+
return False
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _count_legacy_migrated(lrn_db: LearningDatabase, profile_id: str) -> int:
|
|
739
|
+
"""Count rows copied forward by the legacy-feedback migration.
|
|
740
|
+
|
|
741
|
+
These rows live in ``learning_signals`` with ``signal_type='legacy_feedback'``
|
|
742
|
+
(see ``learning/legacy_migration.py``). The Brain endpoint exposes this
|
|
743
|
+
separately from the raw ``learning_feedback`` table count so the UI can
|
|
744
|
+
show a honest "pending migration" figure instead of the Stage-8 lie
|
|
745
|
+
that conflated the two.
|
|
746
|
+
"""
|
|
747
|
+
try:
|
|
748
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
749
|
+
conn.row_factory = sqlite3.Row
|
|
750
|
+
try:
|
|
751
|
+
row = conn.execute(
|
|
752
|
+
"SELECT COUNT(*) AS cnt FROM learning_signals "
|
|
753
|
+
"WHERE profile_id = ? AND signal_type = 'legacy_feedback'",
|
|
754
|
+
(profile_id,),
|
|
755
|
+
).fetchone()
|
|
756
|
+
return int(row["cnt"]) if row else 0
|
|
757
|
+
finally:
|
|
758
|
+
conn.close()
|
|
759
|
+
except sqlite3.Error:
|
|
760
|
+
return 0
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _count_signals_since(
|
|
764
|
+
lrn_db: LearningDatabase,
|
|
765
|
+
profile_id: str,
|
|
766
|
+
since: datetime,
|
|
767
|
+
*,
|
|
768
|
+
signal_types: tuple[str, ...] | None = None,
|
|
769
|
+
) -> int:
|
|
770
|
+
"""Count ``learning_signals`` rows since ``since`` for ``profile_id``."""
|
|
771
|
+
try:
|
|
772
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
773
|
+
conn.row_factory = sqlite3.Row
|
|
774
|
+
try:
|
|
775
|
+
if signal_types:
|
|
776
|
+
placeholders = ",".join("?" * len(signal_types))
|
|
777
|
+
sql = (
|
|
778
|
+
"SELECT COUNT(*) AS cnt FROM learning_signals "
|
|
779
|
+
"WHERE profile_id = ? AND created_at >= ? "
|
|
780
|
+
f"AND signal_type IN ({placeholders})"
|
|
781
|
+
)
|
|
782
|
+
params: tuple[Any, ...] = (
|
|
783
|
+
profile_id, since.isoformat(), *signal_types,
|
|
784
|
+
)
|
|
785
|
+
else:
|
|
786
|
+
sql = (
|
|
787
|
+
"SELECT COUNT(*) AS cnt FROM learning_signals "
|
|
788
|
+
"WHERE profile_id = ? AND created_at >= ?"
|
|
789
|
+
)
|
|
790
|
+
params = (profile_id, since.isoformat())
|
|
791
|
+
row = conn.execute(sql, params).fetchone()
|
|
792
|
+
return int(row["cnt"]) if row else 0
|
|
793
|
+
finally:
|
|
794
|
+
conn.close()
|
|
795
|
+
except sqlite3.Error: # pragma: no cover — table always present in tests
|
|
796
|
+
return 0
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _lazy_db_for(profile_id: str) -> LearningDatabase:
|
|
800
|
+
return LearningDatabase(_learning_db_path())
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
_EVOLUTION_MAX_DAYS = 90
|
|
804
|
+
_EVOLUTION_DEFAULT_DAYS = 30
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _compute_evolution_timeseries(
|
|
808
|
+
profile_id: str,
|
|
809
|
+
lrn_db: LearningDatabase,
|
|
810
|
+
*,
|
|
811
|
+
days: int = _EVOLUTION_DEFAULT_DAYS,
|
|
812
|
+
) -> dict:
|
|
813
|
+
"""Daily learning-signal counts for ``profile_id`` over the last ``days``.
|
|
814
|
+
|
|
815
|
+
Returns ``{"days": N, "points": [{"date": "YYYY-MM-DD", "signals": int,
|
|
816
|
+
"patterns_seen": int}, ...], "is_real": True, "source": "learning_signals"}``.
|
|
817
|
+
|
|
818
|
+
Points are left-aligned to midnight UTC and cover exactly ``days``
|
|
819
|
+
consecutive days including today. Missing days are zero-filled so the
|
|
820
|
+
chart renders a flat line instead of a gap.
|
|
821
|
+
"""
|
|
822
|
+
try:
|
|
823
|
+
requested = int(days) if days is not None else _EVOLUTION_DEFAULT_DAYS
|
|
824
|
+
except (TypeError, ValueError):
|
|
825
|
+
requested = _EVOLUTION_DEFAULT_DAYS
|
|
826
|
+
days = max(1, min(requested, _EVOLUTION_MAX_DAYS))
|
|
827
|
+
today = datetime.now(timezone.utc).replace(
|
|
828
|
+
hour=0, minute=0, second=0, microsecond=0,
|
|
829
|
+
)
|
|
830
|
+
start = today - timedelta(days=days - 1)
|
|
831
|
+
|
|
832
|
+
# Daily counts from learning_signals.
|
|
833
|
+
counts: dict[str, int] = {}
|
|
834
|
+
try:
|
|
835
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
836
|
+
conn.row_factory = sqlite3.Row
|
|
837
|
+
try:
|
|
838
|
+
rows = conn.execute(
|
|
839
|
+
"SELECT substr(created_at, 1, 10) AS d, COUNT(*) AS n "
|
|
840
|
+
"FROM learning_signals "
|
|
841
|
+
"WHERE profile_id = ? AND created_at >= ? "
|
|
842
|
+
"GROUP BY substr(created_at, 1, 10)",
|
|
843
|
+
(profile_id, start.isoformat()),
|
|
844
|
+
).fetchall()
|
|
845
|
+
counts = {r["d"]: int(r["n"]) for r in rows if r["d"]}
|
|
846
|
+
finally:
|
|
847
|
+
conn.close()
|
|
848
|
+
except sqlite3.Error:
|
|
849
|
+
counts = {}
|
|
850
|
+
|
|
851
|
+
points: list[dict[str, Any]] = []
|
|
852
|
+
for i in range(days):
|
|
853
|
+
d = start + timedelta(days=i)
|
|
854
|
+
key = d.date().isoformat()
|
|
855
|
+
points.append({
|
|
856
|
+
"date": key,
|
|
857
|
+
"signals": counts.get(key, 0),
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
total = sum(p["signals"] for p in points)
|
|
861
|
+
return {
|
|
862
|
+
"is_real": True,
|
|
863
|
+
"source": "learning_signals",
|
|
864
|
+
"days": days,
|
|
865
|
+
"total_signals": total,
|
|
866
|
+
"points": points,
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _action_outcomes_count(lrn_db: LearningDatabase,
|
|
871
|
+
profile_id: str) -> int:
|
|
872
|
+
"""Row count in ``action_outcomes`` for ``profile_id``.
|
|
873
|
+
|
|
874
|
+
``action_outcomes`` ships in v3.4.22 (M006). While absent, returns 0.
|
|
875
|
+
"""
|
|
876
|
+
try:
|
|
877
|
+
conn = sqlite3.connect(lrn_db.path, timeout=5.0)
|
|
878
|
+
conn.row_factory = sqlite3.Row
|
|
879
|
+
try:
|
|
880
|
+
row = conn.execute(
|
|
881
|
+
"SELECT COUNT(*) AS cnt FROM action_outcomes "
|
|
882
|
+
"WHERE profile_id = ?",
|
|
883
|
+
(profile_id,),
|
|
884
|
+
).fetchone()
|
|
885
|
+
return int(row["cnt"]) if row else 0
|
|
886
|
+
finally:
|
|
887
|
+
conn.close()
|
|
888
|
+
except sqlite3.Error:
|
|
889
|
+
return 0
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# ---------------------------------------------------------------------------
|
|
893
|
+
# Routes
|
|
894
|
+
# ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
@router.get("/brain", dependencies=[Depends(require_install_token)])
|
|
898
|
+
async def get_brain(profile_id: str = "default") -> dict:
|
|
899
|
+
"""Unified Brain endpoint — LLD-04 §3.1.
|
|
900
|
+
|
|
901
|
+
Fan-out: each section is a synchronous SQLite reader. Running them
|
|
902
|
+
serially was a 7-round-trip chain on the hot path (PERF-v2-05).
|
|
903
|
+
We offload each to the default executor via ``asyncio.to_thread``
|
|
904
|
+
and ``asyncio.gather`` so the wall-clock is dominated by the slowest
|
|
905
|
+
single section, not the sum. Every section already swallows its own
|
|
906
|
+
errors and returns honest-empty on failure, so ``return_exceptions``
|
|
907
|
+
is unnecessary — but we still set ``return_exceptions=True`` as a
|
|
908
|
+
belt-and-suspenders guard so one broken reader can't 500 the whole
|
|
909
|
+
endpoint.
|
|
910
|
+
"""
|
|
911
|
+
import asyncio
|
|
912
|
+
|
|
913
|
+
lrn_db = LearningDatabase(_learning_db_path())
|
|
914
|
+
|
|
915
|
+
(
|
|
916
|
+
preferences, learning, usage, bandit_snap, cache,
|
|
917
|
+
cross_platform, outcomes_rows, evolution,
|
|
918
|
+
) = await asyncio.gather(
|
|
919
|
+
asyncio.to_thread(_compute_preferences, profile_id),
|
|
920
|
+
asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
|
|
921
|
+
asyncio.to_thread(_compute_usage_stats, profile_id),
|
|
922
|
+
asyncio.to_thread(_compute_bandit_snapshot, profile_id, lrn_db),
|
|
923
|
+
asyncio.to_thread(_compute_cache_stats),
|
|
924
|
+
asyncio.to_thread(_compute_cross_platform),
|
|
925
|
+
asyncio.to_thread(_action_outcomes_count, lrn_db, profile_id),
|
|
926
|
+
asyncio.to_thread(
|
|
927
|
+
_compute_evolution_timeseries, profile_id, lrn_db,
|
|
928
|
+
days=_EVOLUTION_DEFAULT_DAYS,
|
|
929
|
+
),
|
|
930
|
+
return_exceptions=True,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# Replace any per-section exception with an honest-empty dict so the
|
|
934
|
+
# endpoint never propagates a half-rendered payload.
|
|
935
|
+
def _ok(value, fallback):
|
|
936
|
+
if isinstance(value, Exception):
|
|
937
|
+
return fallback
|
|
938
|
+
return value
|
|
939
|
+
|
|
940
|
+
return {
|
|
941
|
+
"profile_id": profile_id,
|
|
942
|
+
"preferences": _ok(preferences, {"is_real": True,
|
|
943
|
+
"topics": [], "entities": [],
|
|
944
|
+
"tech": [], "redacted_count": 0,
|
|
945
|
+
"source": "_store_patterns"}),
|
|
946
|
+
"learning": _ok(learning, {"is_real": True, "phase": 1,
|
|
947
|
+
"signals_total": 0}),
|
|
948
|
+
"usage": _ok(usage, {"is_real_ml": False,
|
|
949
|
+
"recalls_last_24h": 0,
|
|
950
|
+
"top_query_types": [],
|
|
951
|
+
"top_time_buckets": []}),
|
|
952
|
+
"bandit": _ok(bandit_snap, {"is_real": True,
|
|
953
|
+
"strata_active": 0,
|
|
954
|
+
"strata_total": 48}),
|
|
955
|
+
"cache": _ok(cache, {"is_real": True,
|
|
956
|
+
"db_size_bytes": 0,
|
|
957
|
+
"entry_count": 0}),
|
|
958
|
+
"cross_platform": _ok(cross_platform, {}),
|
|
959
|
+
"evolution_preview": _ok(evolution, {
|
|
960
|
+
"is_real": True, "source": "learning_signals",
|
|
961
|
+
"days": _EVOLUTION_DEFAULT_DAYS, "total_signals": 0, "points": [],
|
|
962
|
+
}),
|
|
963
|
+
"outcomes_preview": {
|
|
964
|
+
"action_outcomes_rows":
|
|
965
|
+
0 if isinstance(outcomes_rows, Exception) else outcomes_rows,
|
|
966
|
+
"ships_in": "3.4.22",
|
|
967
|
+
},
|
|
968
|
+
# S9-defer H-22: live tile data for the Reward / Shadow /
|
|
969
|
+
# Evolution-Cost dashboard tiles. Each block is a honest-empty
|
|
970
|
+
# default when the underlying table is missing (fresh install
|
|
971
|
+
# or older schema) so the UI can render "no data yet" without
|
|
972
|
+
# a 500. All three queries are cheap COUNT / AVG aggregates.
|
|
973
|
+
"reward_preview": _compute_reward_preview(profile_id),
|
|
974
|
+
"shadow_preview": _compute_shadow_preview(profile_id),
|
|
975
|
+
"evolution_cost_preview": _compute_evolution_cost_preview(profile_id),
|
|
976
|
+
"outcome_queue": _compute_outcome_queue_stats(profile_id),
|
|
977
|
+
"meta": _meta_now(),
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _compute_outcome_queue_stats(profile_id: str) -> dict:
|
|
982
|
+
"""S9-DASH-02: producer-side telemetry for the Brain panel.
|
|
983
|
+
|
|
984
|
+
Exposes the outcome-queue worker counters + a live count of
|
|
985
|
+
``pending_outcomes`` so the operator can see the closed loop is
|
|
986
|
+
actually flowing: recall → enqueue → persist → finalize.
|
|
987
|
+
"""
|
|
988
|
+
import sqlite3
|
|
989
|
+
from pathlib import Path
|
|
990
|
+
try:
|
|
991
|
+
from superlocalmemory.learning.outcome_queue import (
|
|
992
|
+
get_counters, queue_size,
|
|
993
|
+
)
|
|
994
|
+
counters = get_counters()
|
|
995
|
+
qsz = queue_size()
|
|
996
|
+
except Exception:
|
|
997
|
+
counters, qsz = {}, 0
|
|
998
|
+
home = Path.home() / ".superlocalmemory"
|
|
999
|
+
db = home / "memory.db"
|
|
1000
|
+
pending_now = 0
|
|
1001
|
+
if db.exists():
|
|
1002
|
+
try:
|
|
1003
|
+
conn = sqlite3.connect(str(db), timeout=1.0)
|
|
1004
|
+
try:
|
|
1005
|
+
row = conn.execute(
|
|
1006
|
+
"SELECT COUNT(*) FROM pending_outcomes "
|
|
1007
|
+
"WHERE profile_id = ? AND status = 'pending'",
|
|
1008
|
+
(profile_id,),
|
|
1009
|
+
).fetchone()
|
|
1010
|
+
if row:
|
|
1011
|
+
pending_now = int(row[0] or 0)
|
|
1012
|
+
finally:
|
|
1013
|
+
conn.close()
|
|
1014
|
+
except Exception:
|
|
1015
|
+
pass
|
|
1016
|
+
return {
|
|
1017
|
+
"is_real": True,
|
|
1018
|
+
"queue_depth": int(qsz),
|
|
1019
|
+
"pending_outcomes_now": pending_now,
|
|
1020
|
+
"counters": counters,
|
|
1021
|
+
"source": "outcome_queue + pending_outcomes",
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
# ---------------------------------------------------------------------------
|
|
1026
|
+
# S9-defer H-22 — dashboard-tile aggregates
|
|
1027
|
+
# ---------------------------------------------------------------------------
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _compute_reward_preview(profile_id: str) -> dict:
|
|
1031
|
+
"""Reward-tile aggregate — count + mean reward over the last 24h."""
|
|
1032
|
+
import sqlite3
|
|
1033
|
+
from pathlib import Path
|
|
1034
|
+
home = Path.home() / ".superlocalmemory"
|
|
1035
|
+
db = home / "memory.db"
|
|
1036
|
+
default = {
|
|
1037
|
+
"is_real": False, "rows_24h": 0, "mean_reward_24h": 0.0,
|
|
1038
|
+
"source": "action_outcomes",
|
|
1039
|
+
}
|
|
1040
|
+
if not db.exists():
|
|
1041
|
+
return default
|
|
1042
|
+
try:
|
|
1043
|
+
conn = sqlite3.connect(str(db), timeout=1.0)
|
|
1044
|
+
try:
|
|
1045
|
+
row = conn.execute(
|
|
1046
|
+
"SELECT COUNT(*) AS c, AVG(reward) AS m "
|
|
1047
|
+
"FROM action_outcomes "
|
|
1048
|
+
"WHERE profile_id = ? AND settled = 1 "
|
|
1049
|
+
" AND reward IS NOT NULL "
|
|
1050
|
+
" AND settled_at >= datetime('now', '-1 day')",
|
|
1051
|
+
(profile_id,),
|
|
1052
|
+
).fetchone()
|
|
1053
|
+
finally:
|
|
1054
|
+
conn.close()
|
|
1055
|
+
except sqlite3.Error:
|
|
1056
|
+
return default
|
|
1057
|
+
if row is None:
|
|
1058
|
+
return default
|
|
1059
|
+
return {
|
|
1060
|
+
"is_real": True,
|
|
1061
|
+
"rows_24h": int(row[0] or 0),
|
|
1062
|
+
"mean_reward_24h": float(row[1] or 0.0),
|
|
1063
|
+
"source": "action_outcomes",
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _compute_shadow_preview(profile_id: str) -> dict:
|
|
1068
|
+
"""Shadow-tile aggregate — latest candidate + paired-obs count."""
|
|
1069
|
+
import sqlite3
|
|
1070
|
+
default = {
|
|
1071
|
+
"is_real": False, "active_candidate_id": None,
|
|
1072
|
+
"paired_observations": 0, "rollback_count_90d": 0,
|
|
1073
|
+
"source": "learning_model_state+shadow_observations",
|
|
1074
|
+
}
|
|
1075
|
+
learning_db = _learning_db_path()
|
|
1076
|
+
if not learning_db.exists():
|
|
1077
|
+
return default
|
|
1078
|
+
try:
|
|
1079
|
+
conn = sqlite3.connect(str(learning_db), timeout=1.0)
|
|
1080
|
+
try:
|
|
1081
|
+
row = conn.execute(
|
|
1082
|
+
"SELECT id FROM learning_model_state "
|
|
1083
|
+
"WHERE profile_id = ? AND is_candidate = 1 "
|
|
1084
|
+
"LIMIT 1",
|
|
1085
|
+
(profile_id,),
|
|
1086
|
+
).fetchone()
|
|
1087
|
+
cand_id = int(row[0]) if row and row[0] is not None else None
|
|
1088
|
+
paired = 0
|
|
1089
|
+
if cand_id is not None:
|
|
1090
|
+
try:
|
|
1091
|
+
c = conn.execute(
|
|
1092
|
+
"SELECT COUNT(DISTINCT query_id) "
|
|
1093
|
+
"FROM shadow_observations "
|
|
1094
|
+
"WHERE candidate_id = ? "
|
|
1095
|
+
" AND query_id IN ("
|
|
1096
|
+
" SELECT query_id FROM shadow_observations "
|
|
1097
|
+
" WHERE candidate_id = ? AND arm = 'active' "
|
|
1098
|
+
" INTERSECT "
|
|
1099
|
+
" SELECT query_id FROM shadow_observations "
|
|
1100
|
+
" WHERE candidate_id = ? AND arm = 'candidate' "
|
|
1101
|
+
" )",
|
|
1102
|
+
(cand_id, cand_id, cand_id),
|
|
1103
|
+
).fetchone()
|
|
1104
|
+
paired = int(c[0]) if c and c[0] is not None else 0
|
|
1105
|
+
except sqlite3.Error:
|
|
1106
|
+
paired = 0
|
|
1107
|
+
# Rollback count in the last 90d. The rollback log is a
|
|
1108
|
+
# separate table the learning subsystem writes; if it is
|
|
1109
|
+
# absent we simply return 0.
|
|
1110
|
+
try:
|
|
1111
|
+
r2 = conn.execute(
|
|
1112
|
+
"SELECT COUNT(*) FROM model_state_history "
|
|
1113
|
+
"WHERE profile_id = ? AND action = 'rollback' "
|
|
1114
|
+
" AND ts >= datetime('now', '-90 day')",
|
|
1115
|
+
(profile_id,),
|
|
1116
|
+
).fetchone()
|
|
1117
|
+
rollbacks = int(r2[0]) if r2 and r2[0] is not None else 0
|
|
1118
|
+
except sqlite3.Error:
|
|
1119
|
+
rollbacks = 0
|
|
1120
|
+
finally:
|
|
1121
|
+
conn.close()
|
|
1122
|
+
except sqlite3.Error:
|
|
1123
|
+
return default
|
|
1124
|
+
return {
|
|
1125
|
+
"is_real": True,
|
|
1126
|
+
"active_candidate_id": cand_id,
|
|
1127
|
+
"paired_observations": paired,
|
|
1128
|
+
"rollback_count_90d": rollbacks,
|
|
1129
|
+
"source": "learning_model_state+shadow_observations",
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _compute_evolution_cost_preview(profile_id: str) -> dict:
|
|
1134
|
+
"""Evolution-cost tile — last 7d spend + call count."""
|
|
1135
|
+
import sqlite3
|
|
1136
|
+
default = {
|
|
1137
|
+
"is_real": False, "calls_7d": 0, "cost_usd_7d": 0.0,
|
|
1138
|
+
"tokens_in_7d": 0, "tokens_out_7d": 0,
|
|
1139
|
+
"source": "evolution_llm_cost_log",
|
|
1140
|
+
}
|
|
1141
|
+
learning_db = _learning_db_path()
|
|
1142
|
+
if not learning_db.exists():
|
|
1143
|
+
return default
|
|
1144
|
+
try:
|
|
1145
|
+
conn = sqlite3.connect(str(learning_db), timeout=1.0)
|
|
1146
|
+
try:
|
|
1147
|
+
row = conn.execute(
|
|
1148
|
+
"SELECT COUNT(*) AS c, "
|
|
1149
|
+
" COALESCE(SUM(cost_usd), 0) AS cost, "
|
|
1150
|
+
" COALESCE(SUM(tokens_in), 0) AS tin, "
|
|
1151
|
+
" COALESCE(SUM(tokens_out), 0) AS tout "
|
|
1152
|
+
"FROM evolution_llm_cost_log "
|
|
1153
|
+
"WHERE profile_id = ? "
|
|
1154
|
+
" AND ts >= datetime('now', '-7 day')",
|
|
1155
|
+
(profile_id,),
|
|
1156
|
+
).fetchone()
|
|
1157
|
+
finally:
|
|
1158
|
+
conn.close()
|
|
1159
|
+
except sqlite3.Error:
|
|
1160
|
+
return default
|
|
1161
|
+
if row is None:
|
|
1162
|
+
return default
|
|
1163
|
+
return {
|
|
1164
|
+
"is_real": True,
|
|
1165
|
+
"calls_7d": int(row[0] or 0),
|
|
1166
|
+
"cost_usd_7d": float(row[1] or 0.0),
|
|
1167
|
+
"tokens_in_7d": int(row[2] or 0),
|
|
1168
|
+
"tokens_out_7d": int(row[3] or 0),
|
|
1169
|
+
"source": "evolution_llm_cost_log",
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
@router.get("/brain/evolution-timeseries",
|
|
1174
|
+
dependencies=[Depends(require_install_token)])
|
|
1175
|
+
async def get_brain_evolution_timeseries(
|
|
1176
|
+
profile_id: str = "default",
|
|
1177
|
+
days: int = _EVOLUTION_DEFAULT_DAYS,
|
|
1178
|
+
) -> dict:
|
|
1179
|
+
"""Daily learning-signal counts for ``profile_id`` over the last ``days``.
|
|
1180
|
+
|
|
1181
|
+
``days`` is clamped to ``[1, 90]`` to keep the response bounded. Each
|
|
1182
|
+
missing day is zero-filled so the chart renders a flat line, not a gap.
|
|
1183
|
+
"""
|
|
1184
|
+
import asyncio
|
|
1185
|
+
|
|
1186
|
+
lrn_db = LearningDatabase(_learning_db_path())
|
|
1187
|
+
result = await asyncio.to_thread(
|
|
1188
|
+
_compute_evolution_timeseries, profile_id, lrn_db, days=days,
|
|
1189
|
+
)
|
|
1190
|
+
result["meta"] = _meta_now()
|
|
1191
|
+
return result
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
# ---------------------------------------------------------------------------
|
|
1195
|
+
# Deprecated shims — 1 release grace. All require the install token.
|
|
1196
|
+
# ---------------------------------------------------------------------------
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
@router.get("/learning/stats",
|
|
1200
|
+
dependencies=[Depends(require_install_token)])
|
|
1201
|
+
async def learning_stats_deprecated(profile_id: str = "default") -> dict:
|
|
1202
|
+
lrn_db = LearningDatabase(_learning_db_path())
|
|
1203
|
+
return {
|
|
1204
|
+
"deprecated": True,
|
|
1205
|
+
"use_instead": "/api/v3/brain",
|
|
1206
|
+
"learning": _compute_learning_status(profile_id, lrn_db),
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
@router.get("/patterns",
|
|
1211
|
+
dependencies=[Depends(require_install_token)])
|
|
1212
|
+
async def patterns_deprecated(profile_id: str = "default") -> dict:
|
|
1213
|
+
return {
|
|
1214
|
+
"deprecated": True,
|
|
1215
|
+
"use_instead": "/api/v3/brain",
|
|
1216
|
+
"preferences": _compute_preferences(profile_id),
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
@router.get("/behavioral",
|
|
1221
|
+
dependencies=[Depends(require_install_token)])
|
|
1222
|
+
async def behavioral_deprecated(profile_id: str = "default") -> dict:
|
|
1223
|
+
return {
|
|
1224
|
+
"deprecated": True,
|
|
1225
|
+
"use_instead": "/api/v3/brain",
|
|
1226
|
+
"usage": _compute_usage_stats(profile_id),
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
__all__ = (
|
|
1231
|
+
"router",
|
|
1232
|
+
"require_install_token",
|
|
1233
|
+
"redact_secrets_in_preferences",
|
|
1234
|
+
)
|