"""SQLite storage layer: schema init, idempotent upserts, and read queries. All writes are UPSERTs keyed on (source, source_id), so re-running a sync over an overlapping id range is a no-op rather than a duplicate. Reads back the Adolf query CLI (`cli.py query ...`). """ import os import sqlite3 from datetime import datetime, timezone SCHEMA_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "schema.sql") def _now_iso(): return datetime.now(tz=timezone.utc).isoformat() def connect(db_path): os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True) conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row return conn def init_db(conn): with open(SCHEMA_PATH) as f: conn.executescript(f.read()) conn.commit() # --- writes --------------------------------------------------------------- def upsert_entries(conn, rows, source="moodtracker"): now = _now_iso() n = 0 for r in rows: conn.execute( """ INSERT INTO mood_entries (source, source_id, ts, mood, tags, note, affirmation, ingested_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(source, source_id) DO UPDATE SET ts=excluded.ts, mood=excluded.mood, tags=excluded.tags, note=excluded.note, affirmation=excluded.affirmation, ingested_at=excluded.ingested_at """, ( source, r["id"], r["ts"], r["mood"], r.get("tags", "[]"), r.get("note"), r.get("affirmation"), now, ), ) n += 1 conn.commit() return n # --- sync cursor & run audit --------------------------------------------- def get_last_synced_id(conn, stream_key): row = conn.execute( "SELECT last_synced_id FROM sync_state WHERE stream_key=?", (stream_key,) ).fetchone() return row["last_synced_id"] if row else 0 def set_sync_state(conn, stream_key, last_synced_id, status="ok", error=None): conn.execute( """ INSERT INTO sync_state (stream_key, last_synced_id, last_run_at, last_status, last_error) VALUES (?,?,?,?,?) ON CONFLICT(stream_key) DO UPDATE SET last_synced_id=MAX(sync_state.last_synced_id, excluded.last_synced_id), last_run_at=excluded.last_run_at, last_status=excluded.last_status, last_error=excluded.last_error """, (stream_key, last_synced_id, _now_iso(), status, error), ) conn.commit() def start_run(conn): cur = conn.execute( "INSERT INTO ingest_runs (started_at, status) VALUES (?, 'running')", (_now_iso(),) ) conn.commit() return cur.lastrowid def finish_run(conn, run_id, status, entries=0, error=None): conn.execute( """UPDATE ingest_runs SET finished_at=?, status=?, entries_upserted=?, error=? WHERE id=?""", (_now_iso(), status, entries, error, run_id), ) conn.commit() # --- reads (Adolf query tool + reports) ----------------------------------- def summary(conn): """Compact snapshot: count, coverage, freshness, last run.""" out = {} out["entries"] = conn.execute("SELECT COUNT(*) c FROM mood_entries").fetchone()["c"] span = conn.execute("SELECT MIN(ts) a, MAX(ts) b FROM mood_entries").fetchone() out["coverage"] = {"earliest": span["a"], "latest": span["b"]} out["avg_mood_all_time"] = conn.execute( "SELECT ROUND(AVG(mood), 2) a FROM mood_entries" ).fetchone()["a"] out["sync_state"] = [dict(r) for r in conn.execute( "SELECT stream_key, last_synced_id, last_run_at, last_status FROM sync_state" ).fetchall()] last = conn.execute( "SELECT started_at, finished_at, status, entries_upserted FROM ingest_runs " "ORDER BY id DESC LIMIT 1" ).fetchone() out["last_run"] = dict(last) if last else None return out def recent_entries(conn, days=30, limit=200): return [dict(r) for r in conn.execute( """SELECT source_id, ts, mood, tags, note, affirmation FROM mood_entries WHERE ts >= datetime('now', ?) ORDER BY ts DESC LIMIT ?""", (f"-{int(days)} days", int(limit)), ).fetchall()] def daily_mood(conn, days=30): """Average mood and entry count per calendar day (report #1: mood over time).""" return [dict(r) for r in conn.execute( """SELECT date(ts) AS day, ROUND(AVG(mood), 2) AS avg_mood, COUNT(*) AS entries FROM mood_entries WHERE ts >= datetime('now', ?) GROUP BY day ORDER BY day DESC""", (f"-{int(days)} days",), ).fetchall()] def tag_breakdown(conn, days=90, min_count=2): """Average mood per tag (simple correlation: which tags co-occur with higher/lower mood). Tags are stored as a JSON array per entry; this unpacks them in Python since SQLite has no native JSON array explode without the (not always compiled-in) json1 table-valued functions.""" import json rows = conn.execute( "SELECT mood, tags FROM mood_entries WHERE ts >= datetime('now', ?)", (f"-{int(days)} days",), ).fetchall() by_tag = {} for r in rows: try: tags = json.loads(r["tags"]) or [] except (TypeError, ValueError): tags = [] for t in tags: by_tag.setdefault(t, []).append(r["mood"]) out = [ {"tag": t, "avg_mood": round(sum(v) / len(v), 2), "count": len(v)} for t, v in by_tag.items() if len(v) >= min_count ] out.sort(key=lambda x: x["avg_mood"]) return out # --- correlation hook (generic, no other Agap service wired) -------------- def correlate_with_series(conn, external_daily, days=90): """Pearson correlation between daily average mood and an arbitrary externally-supplied daily series. `external_daily` is a dict {'YYYY-MM-DD': float}. This is a deliberate seam for future cross-source correlation (e.g. googlefit sleep/steps) — it takes plain data, not a live connection to another service's DB, so wiring a second source later is a one-line change at the call site (build the dict from that source's own query CLI) and never requires this service to know about the other service's schema or container. Returns {'n': overlap_days, 'r': pearson_r_or_None, 'points': [...]}.""" mood_by_day = { r["day"]: r["avg_mood"] for r in daily_mood(conn, days=days) } common_days = sorted(set(mood_by_day) & set(external_daily)) xs = [mood_by_day[d] for d in common_days] ys = [external_daily[d] for d in common_days] n = len(xs) if n < 2: return {"n": n, "r": None, "points": list(zip(common_days, xs, ys))} mx, my = sum(xs) / n, sum(ys) / n cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) varx = sum((x - mx) ** 2 for x in xs) vary = sum((y - my) ** 2 for y in ys) r = cov / (varx ** 0.5 * vary ** 0.5) if varx > 0 and vary > 0 else None return { "n": n, "r": round(r, 3) if r is not None else None, "points": [{"day": d, "mood": x, "value": y} for d, x, y in zip(common_days, xs, ys)], }