Compose and supporting code for four services that had been running or prototyped without their config tracked here, per the repo convention that agap_git holds the compose + config while application source lives in each service's own Gitea repo. Only placeholder credentials are included: mood/.env.example and moodtracker/.env.example ship dummy values, and overleaf/variables.env carries app name and feature flags only. Real values stay in Vaultwarden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
7.1 KiB
Python
203 lines
7.1 KiB
Python
"""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)],
|
|
}
|