plan migrace /remind na sqlite

This commit is contained in:
lachtan
2026-06-10 06:44:58 +02:00
parent 67e29c8b88
commit be01fe7a07

View File

@@ -0,0 +1,161 @@
# Přechod /remind skillu z YAML na SQLite
## Kontext
Současný /remind skill ukládá reminder data do `reminder.yaml` a dedup state do `.reminder_state.json`. YAML má známé problémy: žádné transakce, celý soubor se načítá do paměti, dedup je hrubý (hash textu), chybí audit trail pro CRUD operace. Cílem je přejít na SQLite jako primární storage s textovým logem pro audit.
## Postup
### 1. Schéma a inicializace databáze
**Cesta k DB:** `workspace/db/reminders.sqlite` (podle pravidel v AGENTS.md).
Vytvořit `skills/remind/scripts/db.py`:
- Funkce `init_db(path)` — spustí CREATE TABLE/INDEX z schématu níže
- Funkce `get_db(path)` — vrací connection s WAL mode a foreign keys
- Funkce `log_operation(operation, reminder_id, details)` — append do `workspace/log/reminder.log`
Schéma:
```sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL CHECK(text <> ''),
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
timezone TEXT NOT NULL DEFAULT 'Europe/Prague',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS schedule_at (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
at_datetime TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS schedule_cron (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
cron_expr TEXT NOT NULL CHECK(cron_expr <> '')
);
CREATE TABLE IF NOT EXISTS schedule_random (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
times_per_day INTEGER NOT NULL CHECK(times_per_day >= 1),
window_start INTEGER NOT NULL CHECK(window_start >= 0 AND window_start < 1440),
window_end INTEGER NOT NULL CHECK(window_end > 0 AND window_end <= 1440),
days_filter TEXT,
from_date TEXT,
until_date TEXT,
CHECK(window_start < window_end)
);
CREATE TABLE IF NOT EXISTS reminder_fires (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders(id) ON DELETE CASCADE,
schedule_id INTEGER NOT NULL,
schedule_type TEXT NOT NULL CHECK(schedule_type IN ('at', 'cron', 'random')),
fire_time TEXT NOT NULL,
delivered_at TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'delivered', 'failed')),
error_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_reminders_text ON reminders(text);
CREATE INDEX IF NOT EXISTS idx_at_datetime ON schedule_at(reminder_id, at_datetime);
CREATE INDEX IF NOT EXISTS idx_cron_expr ON schedule_cron(reminder_id, cron_expr);
CREATE INDEX IF NOT EXISTS idx_random_dates ON schedule_random(from_date, until_date);
CREATE INDEX IF NOT EXISTS idx_fire_lookup ON reminder_fires(
reminder_id, schedule_type, schedule_id, fire_time, status
);
```
### 2. Přepsat `remind_edit.py`
Změnit backend z YAML na SQLite, zachovat CLI rozhraní. Všechny operace v jedné SQLite transakci.
**Subcommandy:**
- `list` — SQL JOIN místo `yaml.safe_load`. Výstup: JSON pole reminderů se scheduly.
- `add``INSERT INTO reminders` → získat `lastrowid``INSERT INTO schedule_*` podle parametrů. Podporuje kombinaci `at`, `cron`, `random` v jednom reminderu.
- `remove --keyword``SELECT id FROM reminders WHERE text LIKE '%keyword%'``UPDATE deleted_at = now` (soft delete). Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení.
- `edit --keyword``UPDATE reminders.text` nebo přidání/odebrání schedulů. Pokud keyword matchne více, vypsat seznam a vyžádat potvrzení.
- `enable --keyword` / `disable --keyword``UPDATE reminders SET enabled = 0/1`.
**Log formát do `workspace/log/reminder.log`:**
```
2026-06-10T06:40:00 [ADD] id=42 text="..." at=2026-06-11T09:00:00
2026-06-10T06:41:00 [REMOVE] id=42 text="..."
2026-06-10T06:42:00 [EDIT] id=42 text="..."
2026-06-10T06:43:00 [ENABLE] id=42
2026-06-10T06:44:00 [DISABLE] id=42
```
### 3. Přepsat `remind_send.py`
Změnit z YAML+JSON state na SQLite. Cesta k DB přes argument nebo default `workspace/db/reminders.sqlite`.
**Algoritmus:**
1. Query pro všechny due fires (at + cron + random) v jednom SELECT s UNION ALL.
2. Pro každý fire: zkontrolovat `reminder_fires` — pokud existuje řádek se stejným `reminder_id + schedule_id + schedule_type + fire_time` a `status = 'delivered'`, přeskočit.
3. Odeslat Telegram notifikaci.
4. `INSERT INTO reminder_fires (... status='delivered', delivered_at=now)`.
5. Při chybě odeslání: `INSERT INTO reminder_fires (... status='failed', error_message=e)` — příští běh retry.
6. Log do `reminder.log`: `2026-06-10T09:00:00 [DELIVER] id=42 text="..."`
**Cron job** — příkaz zůstává stejný (`uv run skills/remind/scripts/remind_send.py`), skript si sám najde DB.
**Query pattern (at):**
```sql
SELECT r.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 r.deleted_at IS NULL
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'
)
```
**Query pattern (cron):** Načíst všechny aktivní cron expr, v Pythonu přes `croniter` vypočítat poslední fire time, porovnat s `now - 60s`.
**Query pattern (random):** Načíst všechny aktivní random scheduly, v Pythonu přes `compute_fire_times(date.today(), ...)` vypočítat fire times, porovnat s `now - 60s`.
### 4. Migrační skript
Vytvořit `scripts/migrate_yaml_to_sqlite.py`:
- Načte `reminder.yaml`
- `BEGIN TRANSACTION`
- Pro každý reminder: `INSERT INTO reminders` → získat `lastrowid`
- Podle polí `at` / `at_times` / `cron_exprs` / `random` vložit do příslušných schedule tabulek
- `COMMIT`
- Přejmenovat `reminder.yaml``reminder.yaml.bak`
### 5. Testy
- `tests/test_db.py` — inicializace schématu, INSERT/SELECT/UPDATE/DELETE, foreign keys, constraints
- `tests/test_remind_edit.py` — add, remove, list, edit, enable/disable s `:memory:` databází
- `tests/test_remind_send.py` — due detection, dedup, retry failed, log output
- Aktualizovat `tests/test_random_times.py` — stále platný, random_times.py se nemění
### 6. Aktualizovat SKILL.md a dokumentaci
- Popsat nové DB storage místo YAML
- Popsat `reminder.log` formát
- Aktualizovat `reminder.example.yaml` na SQL příklady nebo odstranit
## Ověření
1. Spustit `pytest skills/remind/tests/` — všechny testy procházejí
2. Spustit migrační skript — `reminder.yaml` se převede, `.bak` vznikne
3. Ručně přidat reminder přes `remind_edit.py add` — ověřit v DB přes `sqlite3`
4. Spustit `remind_send.py` — ověřit delivery a log
5. Ověřit dedup: spustit send dvakrát ve stejné minutě — druhý běh nesmí poslat duplikát
6. Ověřit `remind_edit.py list` — zobrazí všechny aktivní reminder
7. Ověřit `remind_edit.py remove --keyword` — soft delete, záznam zůstane v DB s `deleted_at`