Files
AgapHost/mood/src/store.py
alvis d37801806d services: add mood, moodtracker, overleaf, personal-sensing; update ollama
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>
2026-07-30 04:42:58 +00:00

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)],
}