dosync 0.4.1__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.
- dosync/__init__.py +17 -0
- dosync/adapters/__init__.py +258 -0
- dosync/adapters/ble.py +199 -0
- dosync/adapters/homeassistant.py +655 -0
- dosync/adapters/matter.py +320 -0
- dosync/adapters/mavlink.py +1205 -0
- dosync/adapters/mqtt.py +409 -0
- dosync/adapters/notifications.py +153 -0
- dosync/adapters/shelly.py +348 -0
- dosync/adapters/wiz.py +357 -0
- dosync/audit_backup.py +184 -0
- dosync/auth.py +194 -0
- dosync/auth_fastapi.py +87 -0
- dosync/cert_signing.py +117 -0
- dosync/certify.py +1091 -0
- dosync/cli.py +61 -0
- dosync/composite_operations.py +306 -0
- dosync/db.py +826 -0
- dosync/device_arbiter.py +270 -0
- dosync/discovery.py +208 -0
- dosync/ed25519_pure.py +204 -0
- dosync/executor.py +97 -0
- dosync/geo.py +63 -0
- dosync/hub.py +2923 -0
- dosync/hub_monitor.py +144 -0
- dosync/manage.py +913 -0
- dosync/mcp_server.py +746 -0
- dosync/metrics.py +244 -0
- dosync/models.py +562 -0
- dosync/operation_guards.py +228 -0
- dosync/operation_supervisor.py +216 -0
- dosync/operations.py +331 -0
- dosync/policies.py +1210 -0
- dosync/policy_config.py +252 -0
- dosync/py.typed +0 -0
- dosync/reconciler.py +177 -0
- dosync/route_composer.py +189 -0
- dosync/security.py +680 -0
- dosync/server.py +1911 -0
- dosync/validation.py +98 -0
- dosync-0.4.1.dist-info/METADATA +372 -0
- dosync-0.4.1.dist-info/RECORD +46 -0
- dosync-0.4.1.dist-info/WHEEL +5 -0
- dosync-0.4.1.dist-info/entry_points.txt +4 -0
- dosync-0.4.1.dist-info/licenses/LICENSE +201 -0
- dosync-0.4.1.dist-info/top_level.txt +1 -0
dosync/db.py
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DoSync — Persistence Layer (SQLite)
|
|
3
|
+
Todos los datos criticos del hub sobreviven reinicios.
|
|
4
|
+
|
|
5
|
+
Un solo archivo dosync.db en el directorio del proyecto.
|
|
6
|
+
Sin dependencias externas — sqlite3 viene con Python.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import sqlite3
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("dosync.db")
|
|
19
|
+
|
|
20
|
+
# Schema SQL
|
|
21
|
+
SCHEMA = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS devices (
|
|
23
|
+
device_id TEXT PRIMARY KEY,
|
|
24
|
+
manifest_json TEXT NOT NULL,
|
|
25
|
+
registered_at REAL NOT NULL,
|
|
26
|
+
updated_at REAL NOT NULL
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS family_profile (
|
|
30
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
31
|
+
profile_json TEXT NOT NULL,
|
|
32
|
+
updated_at REAL NOT NULL
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
entry_json TEXT NOT NULL,
|
|
38
|
+
hash TEXT NOT NULL,
|
|
39
|
+
timestamp REAL NOT NULL
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- AUDIT-ARCHIVE (2026-07-19): metadata for chain segmentation. When older
|
|
43
|
+
-- entries are archived to a segment file, the live chain no longer starts at
|
|
44
|
+
-- genesis — it starts at the ANCHOR (the last archived entry's hash), stored
|
|
45
|
+
-- here so verification and hub restore know where the chain begins.
|
|
46
|
+
CREATE TABLE IF NOT EXISTS audit_meta (
|
|
47
|
+
key TEXT PRIMARY KEY,
|
|
48
|
+
value_json TEXT NOT NULL
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS presence_signals (
|
|
52
|
+
device_id TEXT PRIMARY KEY,
|
|
53
|
+
signal_json TEXT NOT NULL,
|
|
54
|
+
timestamp REAL NOT NULL
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
58
|
+
key_hash TEXT PRIMARY KEY,
|
|
59
|
+
label TEXT NOT NULL,
|
|
60
|
+
created_at REAL NOT NULL,
|
|
61
|
+
last_used_at REAL
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_presence_timestamp ON presence_signals(timestamp);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS device_health (
|
|
67
|
+
device_id TEXT NOT NULL,
|
|
68
|
+
action TEXT NOT NULL,
|
|
69
|
+
success INTEGER NOT NULL, -- 1 = ok, 0 = fail
|
|
70
|
+
error TEXT,
|
|
71
|
+
timestamp REAL NOT NULL
|
|
72
|
+
);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_health_device ON device_health(device_id, timestamp);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS device_state (
|
|
75
|
+
device_id TEXT PRIMARY KEY,
|
|
76
|
+
state_json TEXT NOT NULL,
|
|
77
|
+
updated_at REAL NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS intent_classes (
|
|
80
|
+
name TEXT PRIMARY KEY,
|
|
81
|
+
urgency TEXT NOT NULL DEFAULT 'info',
|
|
82
|
+
resolution_tags TEXT NOT NULL DEFAULT '[]',
|
|
83
|
+
resolution_actuators TEXT NOT NULL DEFAULT '[]',
|
|
84
|
+
description TEXT NOT NULL DEFAULT '',
|
|
85
|
+
domain TEXT NOT NULL DEFAULT 'general',
|
|
86
|
+
is_universal INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
composition_kind TEXT DEFAULT NULL,
|
|
88
|
+
created_at REAL NOT NULL
|
|
89
|
+
);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS rate_limit_log (
|
|
91
|
+
device_id TEXT NOT NULL,
|
|
92
|
+
ts REAL NOT NULL
|
|
93
|
+
);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limit_device_ts ON rate_limit_log (device_id, ts);
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DoSyncDB:
|
|
99
|
+
"""
|
|
100
|
+
Capa de persistencia SQLite para el DoSync Hub.
|
|
101
|
+
|
|
102
|
+
Uso:
|
|
103
|
+
db = DoSyncDB("dosync.db")
|
|
104
|
+
db.init()
|
|
105
|
+
|
|
106
|
+
# Guardar un dispositivo
|
|
107
|
+
db.save_device("lock-01", manifest_dict)
|
|
108
|
+
|
|
109
|
+
# Cargar todos los dispositivos al iniciar el hub
|
|
110
|
+
devices = db.load_devices()
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, db_path: str = "dosync.db"):
|
|
114
|
+
self.db_path = Path(db_path)
|
|
115
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
116
|
+
|
|
117
|
+
# ── Conexion ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def init(self) -> None:
|
|
120
|
+
"""Abre la conexion y crea las tablas si no existen."""
|
|
121
|
+
self._conn = sqlite3.connect(
|
|
122
|
+
str(self.db_path),
|
|
123
|
+
check_same_thread=False, # FastAPI usa multiples threads
|
|
124
|
+
)
|
|
125
|
+
self._conn.row_factory = sqlite3.Row
|
|
126
|
+
self._conn.execute("PRAGMA journal_mode=WAL") # mejor concurrencia
|
|
127
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
128
|
+
self._conn.executescript(SCHEMA)
|
|
129
|
+
self._conn.commit()
|
|
130
|
+
self._migrate_schema()
|
|
131
|
+
log.info("Database initialized: %s", self.db_path.resolve())
|
|
132
|
+
self._seed_universal_intents()
|
|
133
|
+
|
|
134
|
+
def _migrate_schema(self) -> None:
|
|
135
|
+
"""Idempotent, additive migrations for databases created before a column
|
|
136
|
+
existed. CREATE TABLE IF NOT EXISTS never alters an existing table, so new
|
|
137
|
+
nullable columns are added here. Safe to run on every startup: each ADD
|
|
138
|
+
COLUMN is guarded by a column-existence check, so an already-migrated DB is
|
|
139
|
+
untouched and no existing row is affected.
|
|
140
|
+
"""
|
|
141
|
+
def _has_column(table: str, column: str) -> bool:
|
|
142
|
+
rows = self._conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
143
|
+
return any(r["name"] == column for r in rows)
|
|
144
|
+
|
|
145
|
+
# composition_kind: marks an intent class as a composition intent (e.g.
|
|
146
|
+
# "perimeter" for inspect_area). NULL = a normal flat intent (unchanged path).
|
|
147
|
+
if not _has_column("intent_classes", "composition_kind"):
|
|
148
|
+
self._conn.execute(
|
|
149
|
+
"ALTER TABLE intent_classes ADD COLUMN composition_kind TEXT DEFAULT NULL")
|
|
150
|
+
self._conn.commit()
|
|
151
|
+
log.info("Migration: added intent_classes.composition_kind")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _seed_universal_intents(self) -> None:
|
|
155
|
+
"""Seed the 5 universal intent classes if not already present.
|
|
156
|
+
These are the only intents defined at the protocol level — valid in any domain."""
|
|
157
|
+
import json, time
|
|
158
|
+
universals = [
|
|
159
|
+
("ensure_safety", "emergency", ["emergency","alarm","communication","notification"], ["alarm","notify","call","turn_on","set_brightness"], "Safety emergency — protect people and property", "universal", 1),
|
|
160
|
+
("alert_anomaly", "alert", ["communication","notification","sensor"], ["notify","call"], "Unexpected condition detected — investigate", "universal", 1),
|
|
161
|
+
("control_access", "alert", ["lock"], ["lock","unlock"], "Manage physical access to a space", "universal", 1),
|
|
162
|
+
("report_status", "info", [], [], "Generate a status report of the environment", "universal", 1),
|
|
163
|
+
("notify", "info", ["communication","notification","display"], ["notify","display","call"], "Push information to any target", "universal", 1),
|
|
164
|
+
]
|
|
165
|
+
now = time.time()
|
|
166
|
+
for name, urgency, tags, actuators, desc, domain, is_univ in universals:
|
|
167
|
+
row = self._conn.execute(
|
|
168
|
+
"SELECT resolution_tags, resolution_actuators FROM intent_classes WHERE name = ?", (name,)
|
|
169
|
+
).fetchone()
|
|
170
|
+
if not row:
|
|
171
|
+
self._conn.execute(
|
|
172
|
+
"INSERT INTO intent_classes (name,urgency,resolution_tags,resolution_actuators,description,domain,is_universal,created_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
173
|
+
(name, urgency, json.dumps(tags), json.dumps(actuators), desc, domain, is_univ, now)
|
|
174
|
+
)
|
|
175
|
+
elif row[0] != json.dumps(tags) or row[1] != json.dumps(actuators):
|
|
176
|
+
# Universal intent classes are protocol-defined, not user data:
|
|
177
|
+
# reconcile existing deployments to the canonical definition on
|
|
178
|
+
# startup (custom/domain classes are never touched). Without this,
|
|
179
|
+
# a seed fix would never reach an already-initialized DB.
|
|
180
|
+
self._conn.execute(
|
|
181
|
+
"UPDATE intent_classes SET urgency=?, resolution_tags=?, resolution_actuators=?, description=? WHERE name=? AND is_universal=1",
|
|
182
|
+
(urgency, json.dumps(tags), json.dumps(actuators), desc, name)
|
|
183
|
+
)
|
|
184
|
+
self._conn.commit()
|
|
185
|
+
|
|
186
|
+
def close(self) -> None:
|
|
187
|
+
if self._conn:
|
|
188
|
+
self._conn.close()
|
|
189
|
+
self._conn = None
|
|
190
|
+
|
|
191
|
+
@contextmanager
|
|
192
|
+
def _cursor(self):
|
|
193
|
+
cur = self._conn.cursor()
|
|
194
|
+
try:
|
|
195
|
+
yield cur
|
|
196
|
+
self._conn.commit()
|
|
197
|
+
except Exception:
|
|
198
|
+
self._conn.rollback()
|
|
199
|
+
raise
|
|
200
|
+
finally:
|
|
201
|
+
cur.close()
|
|
202
|
+
|
|
203
|
+
# ── Device registry ───────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def save_device(self, device_id: str, manifest: dict) -> None:
|
|
206
|
+
"""Guarda o actualiza un dispositivo."""
|
|
207
|
+
now = time.time()
|
|
208
|
+
with self._cursor() as cur:
|
|
209
|
+
cur.execute("""
|
|
210
|
+
INSERT INTO devices (device_id, manifest_json, registered_at, updated_at)
|
|
211
|
+
VALUES (?, ?, ?, ?)
|
|
212
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
213
|
+
manifest_json = excluded.manifest_json,
|
|
214
|
+
updated_at = excluded.updated_at
|
|
215
|
+
""", (device_id, json.dumps(manifest), now, now))
|
|
216
|
+
log.debug("Saved device: %s", device_id)
|
|
217
|
+
|
|
218
|
+
def delete_device(self, device_id: str) -> None:
|
|
219
|
+
"""Elimina un dispositivo del registry."""
|
|
220
|
+
with self._cursor() as cur:
|
|
221
|
+
cur.execute("DELETE FROM devices WHERE device_id = ?", (device_id,))
|
|
222
|
+
log.debug("Deleted device: %s", device_id)
|
|
223
|
+
|
|
224
|
+
def load_devices(self) -> list[dict]:
|
|
225
|
+
"""Carga todos los dispositivos registrados."""
|
|
226
|
+
with self._cursor() as cur:
|
|
227
|
+
cur.execute("SELECT manifest_json FROM devices ORDER BY registered_at")
|
|
228
|
+
rows = cur.fetchall()
|
|
229
|
+
manifests = [json.loads(r["manifest_json"]) for r in rows]
|
|
230
|
+
log.info("Loaded %d device(s) from database", len(manifests))
|
|
231
|
+
return manifests
|
|
232
|
+
|
|
233
|
+
def device_count(self) -> int:
|
|
234
|
+
with self._cursor() as cur:
|
|
235
|
+
cur.execute("SELECT COUNT(*) as n FROM devices")
|
|
236
|
+
return cur.fetchone()["n"]
|
|
237
|
+
|
|
238
|
+
# ── Family profile ────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
def save_family_profile(self, profile: dict) -> None:
|
|
241
|
+
"""Guarda el perfil familiar (unico, siempre id=1)."""
|
|
242
|
+
now = time.time()
|
|
243
|
+
with self._cursor() as cur:
|
|
244
|
+
cur.execute("""
|
|
245
|
+
INSERT INTO family_profile (id, profile_json, updated_at)
|
|
246
|
+
VALUES (1, ?, ?)
|
|
247
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
248
|
+
profile_json = excluded.profile_json,
|
|
249
|
+
updated_at = excluded.updated_at
|
|
250
|
+
""", (json.dumps(profile), now))
|
|
251
|
+
log.debug("Saved family profile")
|
|
252
|
+
|
|
253
|
+
def load_family_profile(self) -> Optional[dict]:
|
|
254
|
+
"""Carga el perfil familiar, o None si no existe."""
|
|
255
|
+
with self._cursor() as cur:
|
|
256
|
+
cur.execute("SELECT profile_json FROM family_profile WHERE id = 1")
|
|
257
|
+
row = cur.fetchone()
|
|
258
|
+
if row:
|
|
259
|
+
log.info("Loaded family profile from database")
|
|
260
|
+
return json.loads(row["profile_json"])
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
# ── Audit log ─────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
def append_audit(self, entry: dict) -> None:
|
|
266
|
+
"""
|
|
267
|
+
Persiste una entrada del audit log.
|
|
268
|
+
El hash ya viene calculado dentro del entry dict.
|
|
269
|
+
"""
|
|
270
|
+
with self._cursor() as cur:
|
|
271
|
+
cur.execute("""
|
|
272
|
+
INSERT INTO audit_log (entry_json, hash, timestamp)
|
|
273
|
+
VALUES (?, ?, ?)
|
|
274
|
+
""", (
|
|
275
|
+
json.dumps(entry),
|
|
276
|
+
entry.get("hash", ""),
|
|
277
|
+
entry.get("timestamp", time.time()),
|
|
278
|
+
))
|
|
279
|
+
|
|
280
|
+
def get_audit_anchor(self) -> dict | None:
|
|
281
|
+
"""The archive anchor, or None if the chain has never been archived."""
|
|
282
|
+
with self._cursor() as cur:
|
|
283
|
+
cur.execute("SELECT value_json FROM audit_meta WHERE key = 'archive_anchor'")
|
|
284
|
+
row = cur.fetchone()
|
|
285
|
+
return json.loads(row[0]) if row else None
|
|
286
|
+
|
|
287
|
+
def set_audit_anchor(self, anchor: dict) -> None:
|
|
288
|
+
with self._cursor() as cur:
|
|
289
|
+
cur.execute("""
|
|
290
|
+
INSERT INTO audit_meta (key, value_json) VALUES ('archive_anchor', ?)
|
|
291
|
+
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
|
|
292
|
+
""", (json.dumps(anchor),))
|
|
293
|
+
|
|
294
|
+
def load_audit_log(self) -> list[dict]:
|
|
295
|
+
"""Carga el audit log completo ordenado por timestamp."""
|
|
296
|
+
with self._cursor() as cur:
|
|
297
|
+
cur.execute(
|
|
298
|
+
"SELECT entry_json FROM audit_log ORDER BY timestamp, id"
|
|
299
|
+
)
|
|
300
|
+
rows = cur.fetchall()
|
|
301
|
+
entries = [json.loads(r["entry_json"]) for r in rows]
|
|
302
|
+
log.info("Loaded %d audit log entries from database", len(entries))
|
|
303
|
+
return entries
|
|
304
|
+
|
|
305
|
+
def audit_count(self) -> int:
|
|
306
|
+
with self._cursor() as cur:
|
|
307
|
+
cur.execute("SELECT COUNT(*) as n FROM audit_log")
|
|
308
|
+
return cur.fetchone()["n"]
|
|
309
|
+
|
|
310
|
+
# ── Rate limit persistence ────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def append_rate_limit_event(self, device_id: str, ts: float) -> None:
|
|
313
|
+
"""Record an actuator action for rate limit tracking."""
|
|
314
|
+
self._conn.execute(
|
|
315
|
+
"INSERT INTO rate_limit_log (device_id, ts) VALUES (?, ?)",
|
|
316
|
+
(device_id, ts),
|
|
317
|
+
)
|
|
318
|
+
self._conn.commit()
|
|
319
|
+
|
|
320
|
+
def load_rate_limit_events(self, window_seconds: float) -> dict[str, list[float]]:
|
|
321
|
+
"""Load all rate limit events within the current window on startup."""
|
|
322
|
+
cutoff = __import__("time").time() - window_seconds
|
|
323
|
+
rows = self._conn.execute(
|
|
324
|
+
"SELECT device_id, ts FROM rate_limit_log WHERE ts >= ? ORDER BY ts",
|
|
325
|
+
(cutoff,),
|
|
326
|
+
).fetchall()
|
|
327
|
+
result: dict[str, list[float]] = {}
|
|
328
|
+
for device_id, ts in rows:
|
|
329
|
+
result.setdefault(device_id, []).append(ts)
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
def purge_rate_limit_events(self, window_seconds: float) -> int:
|
|
333
|
+
"""Delete expired rate limit events. Call periodically to keep table small."""
|
|
334
|
+
cutoff = __import__("time").time() - window_seconds
|
|
335
|
+
cur = self._conn.execute(
|
|
336
|
+
"DELETE FROM rate_limit_log WHERE ts < ?", (cutoff,)
|
|
337
|
+
)
|
|
338
|
+
self._conn.commit()
|
|
339
|
+
return cur.rowcount
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── Presence signals ──────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def save_presence_signal(self, device_id: str, signal: dict) -> None:
|
|
345
|
+
"""Guarda o actualiza la señal de presencia de un dispositivo."""
|
|
346
|
+
now = signal.get("timestamp", time.time())
|
|
347
|
+
with self._cursor() as cur:
|
|
348
|
+
cur.execute("""
|
|
349
|
+
INSERT INTO presence_signals (device_id, signal_json, timestamp)
|
|
350
|
+
VALUES (?, ?, ?)
|
|
351
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
352
|
+
signal_json = excluded.signal_json,
|
|
353
|
+
timestamp = excluded.timestamp
|
|
354
|
+
""", (device_id, json.dumps(signal), now))
|
|
355
|
+
|
|
356
|
+
def load_presence_signals(self) -> list[dict]:
|
|
357
|
+
"""Carga todas las señales de presencia activas."""
|
|
358
|
+
with self._cursor() as cur:
|
|
359
|
+
cur.execute(
|
|
360
|
+
"SELECT signal_json FROM presence_signals ORDER BY timestamp"
|
|
361
|
+
)
|
|
362
|
+
rows = cur.fetchall()
|
|
363
|
+
signals = [json.loads(r["signal_json"]) for r in rows]
|
|
364
|
+
if signals:
|
|
365
|
+
log.info("Loaded %d presence signal(s) from database", len(signals))
|
|
366
|
+
return signals
|
|
367
|
+
|
|
368
|
+
def delete_presence_signal(self, device_id: str) -> None:
|
|
369
|
+
with self._cursor() as cur:
|
|
370
|
+
cur.execute(
|
|
371
|
+
"DELETE FROM presence_signals WHERE device_id = ?", (device_id,)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# ── Stats ─────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
def stats(self) -> dict:
|
|
377
|
+
"""Resumen del estado de la base de datos."""
|
|
378
|
+
return {
|
|
379
|
+
"db_path": str(self.db_path.resolve()),
|
|
380
|
+
"db_size_kb": round(self.db_path.stat().st_size / 1024, 1)
|
|
381
|
+
if self.db_path.exists() else 0,
|
|
382
|
+
"devices": self.device_count(),
|
|
383
|
+
"audit_entries": self.audit_count(),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# ── API Keys ──────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
def save_api_key(self, key_hash: str, label: str) -> None:
|
|
389
|
+
"""Guarda una API key (almacenamos el hash, nunca la key en texto plano)."""
|
|
390
|
+
with self._cursor() as cur:
|
|
391
|
+
cur.execute("""
|
|
392
|
+
INSERT OR IGNORE INTO api_keys (key_hash, label, created_at)
|
|
393
|
+
VALUES (?, ?, ?)
|
|
394
|
+
""", (key_hash, label, time.time()))
|
|
395
|
+
|
|
396
|
+
def verify_api_key(self, key_hash: str) -> bool:
|
|
397
|
+
"""Verifica si una API key existe y actualiza last_used_at."""
|
|
398
|
+
with self._cursor() as cur:
|
|
399
|
+
cur.execute(
|
|
400
|
+
"SELECT key_hash FROM api_keys WHERE key_hash = ?", (key_hash,)
|
|
401
|
+
)
|
|
402
|
+
row = cur.fetchone()
|
|
403
|
+
if row:
|
|
404
|
+
cur.execute(
|
|
405
|
+
"UPDATE api_keys SET last_used_at = ? WHERE key_hash = ?",
|
|
406
|
+
(time.time(), key_hash)
|
|
407
|
+
)
|
|
408
|
+
return True
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
def list_api_keys(self) -> list[dict]:
|
|
412
|
+
"""Lista todas las API keys registradas (sin el hash completo)."""
|
|
413
|
+
with self._cursor() as cur:
|
|
414
|
+
cur.execute("SELECT key_hash, label, created_at, last_used_at FROM api_keys")
|
|
415
|
+
rows = cur.fetchall()
|
|
416
|
+
return [
|
|
417
|
+
{
|
|
418
|
+
"key_preview": r["key_hash"][:8] + "...",
|
|
419
|
+
"label": r["label"],
|
|
420
|
+
"created_at": r["created_at"],
|
|
421
|
+
"last_used_at": r["last_used_at"],
|
|
422
|
+
}
|
|
423
|
+
for r in rows
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
def delete_api_key(self, key_hash: str) -> bool:
|
|
427
|
+
"""Elimina una API key. Retorna True si existía."""
|
|
428
|
+
with self._cursor() as cur:
|
|
429
|
+
cur.execute("DELETE FROM api_keys WHERE key_hash = ?", (key_hash,))
|
|
430
|
+
return cur.rowcount > 0
|
|
431
|
+
|
|
432
|
+
def has_any_key(self) -> bool:
|
|
433
|
+
"""True si hay al menos una API key registrada."""
|
|
434
|
+
with self._cursor() as cur:
|
|
435
|
+
cur.execute("SELECT COUNT(*) as n FROM api_keys")
|
|
436
|
+
return cur.fetchone()["n"] > 0
|
|
437
|
+
|
|
438
|
+
# ── Device State (StateAwareResolver persistence) ─────────────────────────
|
|
439
|
+
|
|
440
|
+
def save_device_state(self, device_id: str, state: dict) -> None:
|
|
441
|
+
"""Persiste el estado de un dispositivo. Upsert por device_id."""
|
|
442
|
+
with self._cursor() as cur:
|
|
443
|
+
cur.execute(
|
|
444
|
+
"""
|
|
445
|
+
INSERT INTO device_state (device_id, state_json, updated_at)
|
|
446
|
+
VALUES (?, ?, ?)
|
|
447
|
+
ON CONFLICT(device_id) DO UPDATE SET
|
|
448
|
+
state_json = excluded.state_json,
|
|
449
|
+
updated_at = excluded.updated_at
|
|
450
|
+
""",
|
|
451
|
+
(device_id, json.dumps(state), time.time()),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def load_device_state(self, device_id: str) -> dict:
|
|
455
|
+
"""Carga el estado de un dispositivo. Retorna {} si no existe."""
|
|
456
|
+
with self._cursor() as cur:
|
|
457
|
+
cur.execute(
|
|
458
|
+
"SELECT state_json FROM device_state WHERE device_id = ?",
|
|
459
|
+
(device_id,),
|
|
460
|
+
)
|
|
461
|
+
row = cur.fetchone()
|
|
462
|
+
if row:
|
|
463
|
+
return json.loads(row[0])
|
|
464
|
+
return {}
|
|
465
|
+
|
|
466
|
+
def load_all_device_states(self) -> dict:
|
|
467
|
+
"""Carga todos los estados persistidos. Retorna {device_id: state_dict}."""
|
|
468
|
+
with self._cursor() as cur:
|
|
469
|
+
cur.execute("SELECT device_id, state_json FROM device_state")
|
|
470
|
+
rows = cur.fetchall()
|
|
471
|
+
return {row[0]: json.loads(row[1]) for row in rows}
|
|
472
|
+
|
|
473
|
+
# ── Device Health Monitor ─────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
def record_execution(self, device_id: str, action: str,
|
|
476
|
+
success: bool, error: str = None) -> None:
|
|
477
|
+
"""Registra el resultado de una ejecución. Llamar tras cada adapter.execute()."""
|
|
478
|
+
with self._cursor() as cur:
|
|
479
|
+
cur.execute(
|
|
480
|
+
"""INSERT INTO device_health (device_id, action, success, error, timestamp)
|
|
481
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
482
|
+
(device_id, action, 1 if success else 0, error, time.time()),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def get_device_health(self, device_id: str, last_n: int = 100) -> dict:
|
|
486
|
+
"""
|
|
487
|
+
Estadísticas de salud de un dispositivo.
|
|
488
|
+
Retorna: {device_id, total, success, failed, success_rate, last_error, last_seen}
|
|
489
|
+
"""
|
|
490
|
+
with self._cursor() as cur:
|
|
491
|
+
cur.execute(
|
|
492
|
+
"""SELECT action, success, error, timestamp
|
|
493
|
+
FROM device_health
|
|
494
|
+
WHERE device_id = ?
|
|
495
|
+
ORDER BY timestamp DESC
|
|
496
|
+
LIMIT ?""",
|
|
497
|
+
(device_id, last_n),
|
|
498
|
+
)
|
|
499
|
+
rows = cur.fetchall()
|
|
500
|
+
|
|
501
|
+
if not rows:
|
|
502
|
+
return {
|
|
503
|
+
"device_id": device_id,
|
|
504
|
+
"total": 0,
|
|
505
|
+
"success": 0,
|
|
506
|
+
"failed": 0,
|
|
507
|
+
"success_rate": None,
|
|
508
|
+
"last_error": None,
|
|
509
|
+
"last_seen": None,
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
total = len(rows)
|
|
513
|
+
success = sum(1 for r in rows if r[1] == 1)
|
|
514
|
+
failed = total - success
|
|
515
|
+
last_error = next((r[2] for r in rows if r[1] == 0), None)
|
|
516
|
+
last_seen = rows[0][3] if rows else None
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
"device_id": device_id,
|
|
520
|
+
"total": total,
|
|
521
|
+
"success": success,
|
|
522
|
+
"failed": failed,
|
|
523
|
+
"success_rate": round(success / total, 3) if total else None,
|
|
524
|
+
"last_error": last_error,
|
|
525
|
+
"last_seen": last_seen,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
def get_all_health(self, last_n: int = 100, min_executions: int = 1) -> list:
|
|
529
|
+
"""
|
|
530
|
+
Estadísticas de salud de todos los dispositivos con al menos min_executions.
|
|
531
|
+
Ordenado por tasa de éxito ascendente (peores primero).
|
|
532
|
+
"""
|
|
533
|
+
with self._cursor() as cur:
|
|
534
|
+
cur.execute("SELECT DISTINCT device_id FROM device_health")
|
|
535
|
+
device_ids = [r[0] for r in cur.fetchall()]
|
|
536
|
+
|
|
537
|
+
results = []
|
|
538
|
+
for device_id in device_ids:
|
|
539
|
+
health = self.get_device_health(device_id, last_n)
|
|
540
|
+
if health["total"] >= min_executions:
|
|
541
|
+
results.append(health)
|
|
542
|
+
|
|
543
|
+
# Peores primero (tasa de éxito ascendente), None al final
|
|
544
|
+
results.sort(key=lambda x: x["success_rate"] if x["success_rate"] is not None else 1.1)
|
|
545
|
+
return results
|
|
546
|
+
|
|
547
|
+
def get_health_alerts(self, threshold: float = 0.7, last_n: int = 100) -> list:
|
|
548
|
+
"""
|
|
549
|
+
Dispositivos cuya tasa de éxito está por debajo del umbral.
|
|
550
|
+
threshold=0.7 significa alertar cuando menos del 70% de las ejecuciones son exitosas.
|
|
551
|
+
"""
|
|
552
|
+
all_health = self.get_all_health(last_n=last_n, min_executions=3)
|
|
553
|
+
return [
|
|
554
|
+
h for h in all_health
|
|
555
|
+
if h["success_rate"] is not None and h["success_rate"] < threshold
|
|
556
|
+
]
|
|
557
|
+
|
|
558
|
+
# ── Device tokens ─────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
def init_device_tokens_table(self) -> None:
|
|
561
|
+
"""Crear tabla device_tokens si no existe."""
|
|
562
|
+
self._conn.execute("""
|
|
563
|
+
CREATE TABLE IF NOT EXISTS device_tokens (
|
|
564
|
+
device_id TEXT PRIMARY KEY,
|
|
565
|
+
token_hash TEXT NOT NULL,
|
|
566
|
+
label TEXT DEFAULT '',
|
|
567
|
+
created_at REAL NOT NULL
|
|
568
|
+
)
|
|
569
|
+
""")
|
|
570
|
+
self._conn.commit()
|
|
571
|
+
|
|
572
|
+
def save_device_token(self, device_id: str, token_hash: str, label: str = "") -> None:
|
|
573
|
+
import time
|
|
574
|
+
self._conn.execute("""
|
|
575
|
+
INSERT OR REPLACE INTO device_tokens (device_id, token_hash, label, created_at)
|
|
576
|
+
VALUES (?, ?, ?, ?)
|
|
577
|
+
""", (device_id, token_hash, label, time.time()))
|
|
578
|
+
self._conn.commit()
|
|
579
|
+
|
|
580
|
+
def verify_device_token(self, device_id: str, token_hash: str) -> bool:
|
|
581
|
+
cur = self._conn.execute(
|
|
582
|
+
"SELECT 1 FROM device_tokens WHERE device_id=? AND token_hash=?",
|
|
583
|
+
(device_id, token_hash)
|
|
584
|
+
)
|
|
585
|
+
return cur.fetchone() is not None
|
|
586
|
+
|
|
587
|
+
def device_is_provisioned(self, device_id: str) -> bool:
|
|
588
|
+
cur = self._conn.execute(
|
|
589
|
+
"SELECT 1 FROM device_tokens WHERE device_id=?", (device_id,)
|
|
590
|
+
)
|
|
591
|
+
return cur.fetchone() is not None
|
|
592
|
+
|
|
593
|
+
def delete_device_token(self, device_id: str) -> bool:
|
|
594
|
+
cur = self._conn.execute(
|
|
595
|
+
"DELETE FROM device_tokens WHERE device_id=?", (device_id,)
|
|
596
|
+
)
|
|
597
|
+
self._conn.commit()
|
|
598
|
+
return cur.rowcount > 0
|
|
599
|
+
|
|
600
|
+
def list_device_tokens(self) -> list[dict]:
|
|
601
|
+
cur = self._conn.execute(
|
|
602
|
+
"SELECT device_id, label, created_at FROM device_tokens ORDER BY created_at DESC"
|
|
603
|
+
)
|
|
604
|
+
return [{"device_id": r[0], "label": r[1], "created_at": r[2]} for r in cur.fetchall()]
|
|
605
|
+
|
|
606
|
+
# ── Emergency snapshots ───────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
def init_emergency_snapshots_table(self) -> None:
|
|
609
|
+
"""Tabla para persistir intents de emergencia activos."""
|
|
610
|
+
self._conn.execute("""
|
|
611
|
+
CREATE TABLE IF NOT EXISTS emergency_snapshots (
|
|
612
|
+
intent_id TEXT PRIMARY KEY,
|
|
613
|
+
intent_class TEXT NOT NULL,
|
|
614
|
+
urgency TEXT NOT NULL,
|
|
615
|
+
context TEXT NOT NULL,
|
|
616
|
+
fired_at REAL NOT NULL,
|
|
617
|
+
resolved_at REAL,
|
|
618
|
+
status TEXT DEFAULT 'active'
|
|
619
|
+
)
|
|
620
|
+
""")
|
|
621
|
+
self._conn.commit()
|
|
622
|
+
|
|
623
|
+
def save_emergency_snapshot(self, intent_id: str, intent_class: str,
|
|
624
|
+
urgency: str, context: dict) -> None:
|
|
625
|
+
import time, json
|
|
626
|
+
self._conn.execute("""
|
|
627
|
+
INSERT OR REPLACE INTO emergency_snapshots
|
|
628
|
+
(intent_id, intent_class, urgency, context, fired_at, status)
|
|
629
|
+
VALUES (?, ?, ?, ?, ?, 'active')
|
|
630
|
+
""", (intent_id, intent_class, urgency, json.dumps(context), time.time()))
|
|
631
|
+
self._conn.commit()
|
|
632
|
+
|
|
633
|
+
def resolve_emergency_snapshot(self, intent_id: str) -> None:
|
|
634
|
+
import time
|
|
635
|
+
self._conn.execute("""
|
|
636
|
+
UPDATE emergency_snapshots
|
|
637
|
+
SET resolved_at=?, status='resolved'
|
|
638
|
+
WHERE intent_id=?
|
|
639
|
+
""", (time.time(), intent_id))
|
|
640
|
+
self._conn.commit()
|
|
641
|
+
|
|
642
|
+
def get_active_emergency_snapshots(self) -> list[dict]:
|
|
643
|
+
import json
|
|
644
|
+
cur = self._conn.execute("""
|
|
645
|
+
SELECT intent_id, intent_class, urgency, context, fired_at
|
|
646
|
+
FROM emergency_snapshots
|
|
647
|
+
WHERE status='active'
|
|
648
|
+
ORDER BY fired_at DESC
|
|
649
|
+
""")
|
|
650
|
+
return [
|
|
651
|
+
{"intent_id": r[0], "intent_class": r[1], "urgency": r[2],
|
|
652
|
+
"context": json.loads(r[3]), "fired_at": r[4]}
|
|
653
|
+
for r in cur.fetchall()
|
|
654
|
+
]
|
|
655
|
+
|
|
656
|
+
def clear_old_snapshots(self, max_age_hours: int = 24) -> int:
|
|
657
|
+
"""Limpia snapshots resueltos o muy antiguos."""
|
|
658
|
+
import time
|
|
659
|
+
cutoff = time.time() - (max_age_hours * 3600)
|
|
660
|
+
cur = self._conn.execute("""
|
|
661
|
+
DELETE FROM emergency_snapshots
|
|
662
|
+
WHERE status='resolved' OR fired_at < ?
|
|
663
|
+
""", (cutoff,))
|
|
664
|
+
self._conn.commit()
|
|
665
|
+
return cur.rowcount
|
|
666
|
+
|
|
667
|
+
# ── Long-running operations (execution_model) ─────────────────────────────
|
|
668
|
+
# Persists active long_running operations so they survive a hub restart. The
|
|
669
|
+
# panel's hard requirement: without this, a hub reboot orphans a drone still
|
|
670
|
+
# flying toward a waypoint — the hub comes back with no record it ever existed.
|
|
671
|
+
# On restart the hub reloads active operations and reconciles them against
|
|
672
|
+
# telemetry (reconciliation lives in the operations/reconciler layer; the DB
|
|
673
|
+
# only stores and retrieves). Follows the emergency_snapshots pattern exactly.
|
|
674
|
+
#
|
|
675
|
+
# The full operation (state machine + transition history) is owned by
|
|
676
|
+
# operations.Operation; here it is stored as its to_dict() JSON blob under a
|
|
677
|
+
# few indexed columns. The DB never interprets the lifecycle — it persists it.
|
|
678
|
+
|
|
679
|
+
def init_operations_table(self) -> None:
|
|
680
|
+
"""Tabla para persistir operaciones long_running activas."""
|
|
681
|
+
self._conn.execute("""
|
|
682
|
+
CREATE TABLE IF NOT EXISTS operations (
|
|
683
|
+
operation_id TEXT PRIMARY KEY,
|
|
684
|
+
device_id TEXT NOT NULL,
|
|
685
|
+
action TEXT NOT NULL,
|
|
686
|
+
state TEXT NOT NULL,
|
|
687
|
+
created_at REAL NOT NULL,
|
|
688
|
+
state_entered_at REAL NOT NULL,
|
|
689
|
+
updated_at REAL NOT NULL,
|
|
690
|
+
terminal INTEGER NOT NULL DEFAULT 0,
|
|
691
|
+
data TEXT NOT NULL
|
|
692
|
+
)
|
|
693
|
+
""")
|
|
694
|
+
self._conn.commit()
|
|
695
|
+
|
|
696
|
+
def save_operation(self, op_dict: dict, terminal: bool) -> None:
|
|
697
|
+
"""Inserta o actualiza una operación. `op_dict` es Operation.to_dict();
|
|
698
|
+
`terminal` indica si la operación llegó a un estado terminal (para que
|
|
699
|
+
get_active_operations la excluya sin reinterpretar el estado)."""
|
|
700
|
+
import time, json
|
|
701
|
+
self._conn.execute("""
|
|
702
|
+
INSERT OR REPLACE INTO operations
|
|
703
|
+
(operation_id, device_id, action, state, created_at,
|
|
704
|
+
state_entered_at, updated_at, terminal, data)
|
|
705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
706
|
+
""", (
|
|
707
|
+
op_dict["operation_id"],
|
|
708
|
+
op_dict["device_id"],
|
|
709
|
+
op_dict["action"],
|
|
710
|
+
op_dict["state"],
|
|
711
|
+
op_dict["created_at"],
|
|
712
|
+
op_dict["state_entered_at"],
|
|
713
|
+
time.time(),
|
|
714
|
+
1 if terminal else 0,
|
|
715
|
+
json.dumps(op_dict),
|
|
716
|
+
))
|
|
717
|
+
self._conn.commit()
|
|
718
|
+
|
|
719
|
+
def get_active_operations(self) -> list[dict]:
|
|
720
|
+
"""Devuelve las operaciones NO terminales — las que siguen vivas y deben
|
|
721
|
+
reconciliarse tras un reinicio. Cada elemento es el Operation.to_dict()
|
|
722
|
+
original, listo para rehidratar."""
|
|
723
|
+
import json
|
|
724
|
+
cur = self._conn.execute("""
|
|
725
|
+
SELECT data FROM operations
|
|
726
|
+
WHERE terminal = 0
|
|
727
|
+
ORDER BY created_at DESC
|
|
728
|
+
""")
|
|
729
|
+
return [json.loads(r[0]) for r in cur.fetchall()]
|
|
730
|
+
|
|
731
|
+
def get_operation(self, operation_id: str) -> dict | None:
|
|
732
|
+
"""Recupera una operación puntual por id (terminal o no)."""
|
|
733
|
+
import json
|
|
734
|
+
cur = self._conn.execute(
|
|
735
|
+
"SELECT data FROM operations WHERE operation_id = ?", (operation_id,)
|
|
736
|
+
)
|
|
737
|
+
row = cur.fetchone()
|
|
738
|
+
return json.loads(row[0]) if row else None
|
|
739
|
+
|
|
740
|
+
def clear_old_operations(self, max_age_hours: int = 24) -> int:
|
|
741
|
+
"""Limpia operaciones terminales o muy antiguas. Las operaciones activas
|
|
742
|
+
(terminal=0) NUNCA se borran por antigüedad — una operación vieja pero
|
|
743
|
+
viva es justamente la que hay que preservar para reconciliar."""
|
|
744
|
+
import time
|
|
745
|
+
cutoff = time.time() - (max_age_hours * 3600)
|
|
746
|
+
cur = self._conn.execute("""
|
|
747
|
+
DELETE FROM operations
|
|
748
|
+
WHERE terminal = 1 AND updated_at < ?
|
|
749
|
+
""", (cutoff,))
|
|
750
|
+
self._conn.commit()
|
|
751
|
+
return cur.rowcount
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# ── Custom Intent Classes ─────────────────────────────────────────────────
|
|
755
|
+
|
|
756
|
+
def save_intent_class(self, name: str, urgency: str,
|
|
757
|
+
resolution_tags: list, resolution_actuators: list,
|
|
758
|
+
description: str, domain: str,
|
|
759
|
+
composition_kind: str | None = None) -> None:
|
|
760
|
+
"""Insert or update an intent class. Never modifies is_universal flag.
|
|
761
|
+
|
|
762
|
+
composition_kind: marks the intent as a composition intent (e.g. "perimeter"
|
|
763
|
+
for inspect_area). None = a normal flat intent. On update, if not provided
|
|
764
|
+
the existing value is preserved (so a plain re-save does not clear it)."""
|
|
765
|
+
import time, json
|
|
766
|
+
existing = self._conn.execute(
|
|
767
|
+
"SELECT is_universal, composition_kind FROM intent_classes WHERE name = ?",
|
|
768
|
+
(name,)
|
|
769
|
+
).fetchone()
|
|
770
|
+
is_universal = existing["is_universal"] if existing else 0
|
|
771
|
+
# Preserve an existing composition_kind unless a new one is explicitly given.
|
|
772
|
+
if composition_kind is None and existing is not None:
|
|
773
|
+
composition_kind = existing["composition_kind"]
|
|
774
|
+
self._conn.execute("""
|
|
775
|
+
INSERT OR REPLACE INTO intent_classes
|
|
776
|
+
(name, urgency, resolution_tags, resolution_actuators,
|
|
777
|
+
description, domain, is_universal, composition_kind, created_at)
|
|
778
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
779
|
+
""", (name, urgency, json.dumps(resolution_tags),
|
|
780
|
+
json.dumps(resolution_actuators),
|
|
781
|
+
description, domain, is_universal, composition_kind, time.time()))
|
|
782
|
+
self._conn.commit()
|
|
783
|
+
|
|
784
|
+
def get_intent_class(self, name: str) -> dict | None:
|
|
785
|
+
import json
|
|
786
|
+
row = self._conn.execute(
|
|
787
|
+
"SELECT * FROM intent_classes WHERE name = ?", (name,)
|
|
788
|
+
).fetchone()
|
|
789
|
+
if not row:
|
|
790
|
+
return None
|
|
791
|
+
return {
|
|
792
|
+
"name": row["name"],
|
|
793
|
+
"urgency": row["urgency"],
|
|
794
|
+
"resolution_tags": json.loads(row["resolution_tags"]),
|
|
795
|
+
"resolution_actuators": json.loads(row["resolution_actuators"]),
|
|
796
|
+
"description": row["description"],
|
|
797
|
+
"domain": row["domain"],
|
|
798
|
+
"created_at": row["created_at"],
|
|
799
|
+
"is_universal": bool(row["is_universal"]),
|
|
800
|
+
"composition_kind": row["composition_kind"] if "composition_kind" in row.keys() else None,
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
def list_intent_classes(self) -> list[dict]:
|
|
804
|
+
import json
|
|
805
|
+
rows = self._conn.execute(
|
|
806
|
+
"SELECT * FROM intent_classes ORDER BY created_at ASC"
|
|
807
|
+
).fetchall()
|
|
808
|
+
return [{
|
|
809
|
+
"name": r["name"],
|
|
810
|
+
"urgency": r["urgency"],
|
|
811
|
+
"resolution_tags": json.loads(r["resolution_tags"]),
|
|
812
|
+
"resolution_actuators": json.loads(r["resolution_actuators"]),
|
|
813
|
+
"description": r["description"],
|
|
814
|
+
"domain": r["domain"],
|
|
815
|
+
"is_universal": bool(r["is_universal"]),
|
|
816
|
+
"composition_kind": r["composition_kind"] if "composition_kind" in r.keys() else None,
|
|
817
|
+
"created_at": r["created_at"],
|
|
818
|
+
} for r in rows]
|
|
819
|
+
|
|
820
|
+
def delete_intent_class(self, name: str) -> bool:
|
|
821
|
+
cur = self._conn.execute(
|
|
822
|
+
"DELETE FROM intent_classes WHERE name = ?", (name,)
|
|
823
|
+
)
|
|
824
|
+
self._conn.commit()
|
|
825
|
+
return cur.rowcount > 0
|
|
826
|
+
|