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>
This commit is contained in:
202
mood/src/store.py
Normal file
202
mood/src/store.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""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)],
|
||||
}
|
||||
Reference in New Issue
Block a user