Zalohovani vsech podstatnych souboru

This commit is contained in:
lachtan
2026-06-10 06:39:52 +02:00
parent 1e10891945
commit 67e29c8b88
69 changed files with 9115 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
# /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`. |