Files
nanobot-runtime/results/2026-06-10_remind-skill-sqlite-redesign.md
2026-06-10 06:39:52 +02:00

261 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /remind skill — návrh přechodu z YAML na SQLite
## 1. Proč SQLite
| Aspekt | YAML (současné) | SQLite (navrhované) |
|--------|-----------------|---------------------|
| Atomicita | tmp+rename, žádné transakce | `BEGIN``COMMIT` |
| Query | Načíst celý soubor do paměti | SELECT s JOIN a indexy |
| Dedup | Externí `.reminder_state.json` | Tabulka `reminder_fires` |
| Datové typy | Vše string | INTEGER, TEXT ISO, CHECK |
| Edit | Chybí (celý záznam se přepisuje) | UPDATE / DELETE per sloupec |
| Testy | File-based, side-effects | `:memory:` databáze |
| Audit | Žádný | `reminder_fires.status` + `error_message` |
## 2. Navrhované schéma
```sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- Hlavní entita -----------------------------------------------------------
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
created_at TEXT NOT NULL, -- ISO-8601
updated_at TEXT NOT NULL, -- ISO-8601
deleted_at TEXT -- soft-delete, NULL = aktivní
);
-- One-time scheduly -------------------------------------------------------
CREATE TABLE schedule_at (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
at_datetime TEXT NOT NULL, -- ISO-8601 (lokální čas dle reminders.timezone)
enabled INTEGER NOT NULL DEFAULT 1
);
-- Recurring cron scheduly -------------------------------------------------
CREATE TABLE schedule_cron (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
cron_expr TEXT NOT NULL, -- standardní cron, např. "0 9 * * 1-5"
enabled INTEGER NOT NULL DEFAULT 1
);
-- Random scheduly ---------------------------------------------------------
CREATE TABLE schedule_random (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
times_per_day INTEGER NOT NULL,
window_start_min INTEGER NOT NULL, -- 0..1439 (minuty od půlnoci)
window_end_min INTEGER NOT NULL, -- 0..1440 (výhradně horní mez)
days_filter TEXT, -- např. "1-5", NULL = každý den
from_date TEXT, -- YYYY-MM-DD, NULL = okamžitě
until_date TEXT, -- YYYY-MM-DD, NULL = navždy
enabled INTEGER NOT NULL DEFAULT 1,
CHECK(times_per_day >= 1),
CHECK(window_start_min >= 0 AND window_start_min < 1440),
CHECK(window_end_min > 0 AND window_end_min <= 1440),
CHECK(window_start_min < window_end_min)
);
-- Audit / dedup / delivery log --------------------------------------------
CREATE TABLE reminder_fires (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
schedule_id INTEGER NOT NULL, -- ID v příslušné schedule_* tabulce
schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at','cron','random')),
fire_time TEXT NOT NULL, -- ISO-8601, plánovaný čas výstřelu
delivered_at TEXT, -- ISO-8601, skutečný čas doručení
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','delivered','failed')),
error_message TEXT
);
-- Indexy ------------------------------------------------------------------
CREATE INDEX idx_reminders_text ON reminders(text);
CREATE INDEX idx_fire_lookup ON reminder_fires(
reminder_id, schedule_type, schedule_id, fire_time, status
);
CREATE INDEX idx_at_datetime ON schedule_at(reminder_id, at_datetime);
CREATE INDEX idx_cron_expr ON schedule_cron(reminder_id, cron_expr);
```
## 3. Lepší datové typy oproti YAML
| Pole (YAML) | SQLite sloupec | Proč lepší |
|-------------|----------------|------------|
| `times_per_day: "5"` (string v YAML) | `times_per_day INTEGER` | Nativní číselná validace, CHECK constraint |
| `window: "09:00-21:00"` (string) | `window_start_min INTEGER`, `window_end_min INTEGER` | Umožňuje matematiku (`fire_minute BETWEEN 540 AND 1260`), sortable |
| `at: "2026-06-10T10:00:00"` | `at_datetime TEXT` | Sice stále TEXT, ale ISO formát je porovnatelný a sortable; SQLite nemá nativní datetime |
| `days: "1-5"` | `days_filter TEXT` | Zůstává TEXT — parsuje se až při běhu; alternativně normalizovat na `random_days(day_of_week INT)`, ale pro 15 položek to je overkill |
| `from` / `until` | `from_date TEXT`, `until_date TEXT` | ISO date je sortable; pro query stačí `date <= '2026-06-10'` |
**Poznámka k časům:** SQLite nemá nativní `DATETIME` typ. Doporučuji ukládat jako **TEXT v ISO-8601** (např. `2026-06-10T10:00:00+02:00`) místo Unix timestampu — je to čitelné, sortable a přímo použitelné s `datetime.fromisoformat()`.
## 4. Jednotlivé typy časů — proč 1:N a ne jedna tabulka
Současný YAML model:
```yaml
- text: "water the plants"
cron_exprs: ["0 19 * * *"]
random: {times_per_day: 2, window: "08:00-12:00"}
```
V DB to rozdělíme na **jeden řádek `reminders`** + **řádky v `schedule_cron` a `schedule_random`**. Důvody:
- **Normalizace**: Každý schedule má svůj životní cyklus — jde zapnout/vypnout, editovat, mazat bez dotyku ostatních.
- **Dedup**: `reminder_fires` odkazuje na konkrétní `schedule_id` + `schedule_type`. Víme přesně, který cron nebo random výstřel už byl doručen.
- **Extensibility**: Přidání nového typu schedule = nová tabulka, není potřeba migrovat existující data.
## 5. Dává smysl ukládat cron jako cron string?
**Ano.**
- Cron je de facto standard, `croniter` ho umí parsovat i expandovat (`get_prev` / `get_next`).
- Rozparsování na `cron_minute INT`, `cron_hour INT` atd. by ztratilo expresivitu (`*/15`, `L`, ranges, step values).
- Ukládání jako cron string je kompaktní a čitelné.
Random schedule **nelze** vyjádřit jako cron — je to vlastní algoritmus `compute_fire_times()`. Proto má samostatnou tabulku s parametry.
## 6. Dedup a state — z `.reminder_state.json` do DB
Současný mechanismus:
```python
key = hashlib.sha1(text.encode()).hexdigest()[:8]
last = state.get(key) # "2026-06-10T09:20:00"
```
Problém: hash textu je hrubý — změna textu znamená nový key, stejný text = stejný key pro všechny scheduly.
Nový mechanismus v SQLite:
```sql
-- Před odesláním:
SELECT 1 FROM reminder_fires
WHERE reminder_id = ? AND schedule_id = ? AND schedule_type = ?
AND fire_time = ? AND status = 'delivered';
```
- Přesná dedup **per schedule**, ne per text.
- `status = 'failed'` umožňuje retry při příštím běhu.
- `error_message` zachytí proč Telegram API selhalo.
- `delivered_at` je audit trail.
## 7. Query pro remind_send.py (místo načítání celého YAML)
```sql
-- Najít všechny due fires za posledních 60 sekund
SELECT
r.id AS reminder_id,
r.text,
'at' AS schedule_type,
sa.id AS schedule_id,
sa.at_datetime AS fire_time
FROM reminders r
JOIN schedule_at sa ON sa.reminder_id = r.id
WHERE r.enabled = 1 AND sa.enabled = 1
AND sa.at_datetime > datetime('now', '-60 seconds')
AND sa.at_datetime <= datetime('now')
AND NOT EXISTS (
SELECT 1 FROM reminder_fires rf
WHERE rf.reminder_id = r.id AND rf.schedule_id = sa.id
AND rf.schedule_type = 'at' AND rf.fire_time = sa.at_datetime
AND rf.status = 'delivered'
)
UNION ALL
-- Cron: vypočítat v Pythonu přes croniter, ale DB řekne které expr existují
SELECT r.id, r.text, 'cron', sc.id, sc.cron_expr
FROM reminders r
JOIN schedule_cron sc ON sc.reminder_id = r.id
WHERE r.enabled = 1 AND sc.enabled = 1;
-- croniter.get_prev() se provede v Pythonu, pak se porovná s now-60s
UNION ALL
-- Random: všechny aktivní random scheduly
SELECT r.id, r.text, 'random', sr.id, NULL
FROM reminders r
JOIN schedule_random sr ON sr.reminder_id = r.id
WHERE r.enabled = 1 AND sr.enabled = 1
AND (sr.from_date IS NULL OR sr.from_date <= date('now'))
AND (sr.until_date IS NULL OR sr.until_date >= date('now'));
-- compute_fire_times(date.today(), ...) se provede v Pythonu
```
## 8. CLI změny
`remind_edit.py` zachová stejné CLI rozhraní, backend se změní:
| Subcommand | Změna |
|------------|-------|
| `list` | SQL JOIN místo `yaml.safe_load` + JSON dump |
| `add` | `INSERT INTO reminders` + `INSERT INTO schedule_*` v jedné transakci |
| `remove --keyword` | `SELECT id FROM reminders WHERE text LIKE '%keyword%'``DELETE` nebo `UPDATE deleted_at` |
| **nové** `edit --keyword` | `UPDATE reminders.text` nebo přidání/odebrání schedulů |
| **nové** `enable` / `disable` | `UPDATE reminders SET enabled = 0/1` |
## 9. Další věci k uvážení
### 9.1 Timezone
- Všechny `at_datetime` a `fire_time` by měly být **aware** (s offsetem `+02:00`) nebo explicitně v `reminders.timezone`.
- Cron výrazy jsou vždy v lokální čase — `croniter` běží nad `datetime.now(TZ)`.
- Doporučení: ukládat jako **TEXT s offsetem** (`2026-06-10T10:00:00+02:00`), při query převádět v Pythonu.
### 9.2 WAL mode
```sql
PRAGMA journal_mode = WAL;
```
Umožní čtení během zápisu. Pro remind_send.py (každou minutu SELECT) + remind_edit.py (občasný INSERT/UPDATE) je to kritické.
### 9.3 Schema versioning
```sql
CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY);
INSERT INTO _schema_version VALUES (1);
```
Při startu skriptu zkontrolovat verzi a spustit migrace.
### 9.4 Testování
- SQLite podporuje `:memory:` databázi — testy mohou běžet bez file I/O.
- `remind_edit.py` dostane parametr `--db PATH` (default `workspace/db/reminders.sqlite`).
### 9.5 Migrace z YAML
Jednorázový skript:
1. Načíst `reminder.yaml`
2. `BEGIN TRANSACTION`
3. Pro každý reminder: `INSERT INTO reminders` → získat `lastrowid`
4. Podle polí `at` / `at_times` / `cron_exprs` / `random` vložit do příslušných schedule tabulek
5. `COMMIT`
6. Přejmenovat `reminder.yaml``reminder.yaml.bak`
### 9.6 Soft delete vs hard delete
- `deleted_at TEXT` místo `DELETE FROM reminders` — zachová historii a umožní "undo".
- `remind_edit.py remove` by default nastaví `deleted_at`, `--hard` by provedl skutečný DELETE.
### 9.7 FTS5 (volitelně)
Pokud bude >100 reminderů, `CREATE VIRTUAL TABLE reminders_fts USING fts5(text)` urychlí fulltext vyhledávání pro `remove --keyword`.
### 9.8 Konfigurace cesty k DB
```python
DEFAULT_DB = Path(__file__).resolve().parent.parent.parent.parent / "db" / "reminders.sqlite"
```
Podle pravidel v AGENTS.md: *Always store SQLite databases under `db/*.sqlite`*.
## 10. Shrnutí rozhodnutí
| Otázka | Rozhodnutí |
|--------|------------|
| Ukládat cron jako string? | **Ano** — standard, expresivní, croniter to zvládne. |
| Random do cron stringu? | **Ne** — random je vlastní algoritmus, ukládat parametry. |
| Jedna tabulka vs schedule tabulky? | **3 schedule tabulky** (at, cron, random) — 1:N vztah. |
| Dedup externě nebo v DB? | **V DB** — `reminder_fires` per schedule. |
| Časy jako TEXT nebo INTEGER? | **TEXT ISO-8601** — čitelné, sortable, Python-compatible. |
| Window jako string nebo minuty? | **INTEGER minuty** — umožňuje SQL matematiku. |
| Hard delete nebo soft delete? | **Soft delete** (`deleted_at`) — audit trail. |
| Transakce? | **Ano** — každý `add` / `remove` / `edit` v `BEGIN…COMMIT`. |