Files
AgapHost/personal-sensing/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

238 lines
8.6 KiB
Python

"""SQLite storage layer: schema init, idempotent upserts, and read queries.
Source-agnostic — the Fit REST adapter and the Takeout importer both call the
same upsert_* functions. Reads (summary/series/sessions/sleep) back the Adolf
query CLI. All writes are UPSERTs keyed on the row's natural Fit identity, so a
re-sync over an overlapping window is a no-op rather than a duplicate.
"""
import json
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
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db(conn):
with open(SCHEMA_PATH) as f:
conn.executescript(f.read())
conn.commit()
# --- writes ---------------------------------------------------------------
def upsert_data_points(conn, rows, source="ha"):
now = _now_iso()
n = 0
for r in rows:
conn.execute(
"""
INSERT INTO data_points
(metric, data_type_name, start_ns, end_ns, start_time, end_time,
value_int, value_float, value_str, unit, data_source_id, source, ingested_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(metric, start_ns, end_ns, data_source_id) DO UPDATE SET
value_int=excluded.value_int,
value_float=excluded.value_float,
value_str=excluded.value_str,
unit=excluded.unit,
data_type_name=excluded.data_type_name,
source=excluded.source,
ingested_at=excluded.ingested_at
""",
(
r["metric"], r["data_type_name"], r["start_ns"], r["end_ns"],
r["start_time"], r["end_time"], r.get("value_int"), r.get("value_float"),
r.get("value_str"), r.get("unit"), r.get("data_source_id", ""), source, now,
),
)
n += 1
conn.commit()
return n
def upsert_sleep_segments(conn, rows, source="ha"):
now = _now_iso()
n = 0
for r in rows:
conn.execute(
"""
INSERT INTO sleep_segments
(start_ns, end_ns, start_time, end_time, stage, stage_name,
data_source_id, source, ingested_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(start_ns, end_ns, data_source_id) DO UPDATE SET
stage=excluded.stage,
stage_name=excluded.stage_name,
source=excluded.source,
ingested_at=excluded.ingested_at
""",
(
r["start_ns"], r["end_ns"], r["start_time"], r["end_time"],
r["stage"], r["stage_name"], r.get("data_source_id", ""), source, now,
),
)
n += 1
conn.commit()
return n
def upsert_sessions(conn, rows, source="ha"):
now = _now_iso()
n = 0
for r in rows:
raw = r.get("raw_json")
if raw is not None and not isinstance(raw, str):
raw = json.dumps(raw)
conn.execute(
"""
INSERT INTO sessions
(id, name, description, activity_type, activity_name, start_ns, end_ns,
start_time, end_time, modified_ns, application, source, raw_json, ingested_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
description=excluded.description,
activity_type=excluded.activity_type,
activity_name=excluded.activity_name,
start_ns=excluded.start_ns,
end_ns=excluded.end_ns,
start_time=excluded.start_time,
end_time=excluded.end_time,
modified_ns=excluded.modified_ns,
application=excluded.application,
source=excluded.source,
raw_json=excluded.raw_json,
ingested_at=excluded.ingested_at
""",
(
r["id"], r.get("name"), r.get("description"), r.get("activity_type"),
r.get("activity_name"), r["start_ns"], r["end_ns"], r["start_time"],
r["end_time"], r.get("modified_ns"), r.get("application"), source, raw, now,
),
)
n += 1
conn.commit()
return n
# --- sync cursor & run audit ---------------------------------------------
def get_last_synced_ns(conn, stream_key):
row = conn.execute(
"SELECT last_synced_ns FROM sync_state WHERE stream_key=?", (stream_key,)
).fetchone()
return row["last_synced_ns"] if row else 0
def set_sync_state(conn, stream_key, last_synced_ns, status="ok", error=None):
conn.execute(
"""
INSERT INTO sync_state (stream_key, last_synced_ns, last_run_at, last_status, last_error)
VALUES (?,?,?,?,?)
ON CONFLICT(stream_key) DO UPDATE SET
last_synced_ns=MAX(sync_state.last_synced_ns, excluded.last_synced_ns),
last_run_at=excluded.last_run_at,
last_status=excluded.last_status,
last_error=excluded.last_error
""",
(stream_key, last_synced_ns, _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, points=0, sessions=0, segments=0, error=None):
conn.execute(
"""UPDATE ingest_runs SET finished_at=?, status=?, points_upserted=?,
sessions_upserted=?, segments_upserted=?, error=? WHERE id=?""",
(_now_iso(), status, points, sessions, segments, error, run_id),
)
conn.commit()
# --- reads (Adolf query tool) --------------------------------------------
def daily_metric(conn, metric, days=14):
return [dict(r) for r in conn.execute(
"""SELECT date(start_time) AS day,
SUM(COALESCE(value_int, value_float)) AS total,
MAX(unit) AS unit
FROM data_points WHERE metric=?
AND start_time >= datetime('now', ?)
GROUP BY day ORDER BY day DESC""",
(metric, f"-{int(days)} days"),
).fetchall()]
def latest_metric(conn, metric, limit=20):
return [dict(r) for r in conn.execute(
"""SELECT start_time, end_time, value_int, value_float, unit
FROM data_points WHERE metric=? ORDER BY start_ns DESC LIMIT ?""",
(metric, int(limit)),
).fetchall()]
def recent_sessions(conn, days=30, limit=50):
return [dict(r) for r in conn.execute(
"""SELECT id, name, activity_type, activity_name, start_time, end_time,
(end_ns - start_ns)/60000000000.0 AS duration_min
FROM sessions WHERE start_time >= datetime('now', ?)
ORDER BY start_ns DESC LIMIT ?""",
(f"-{int(days)} days", int(limit)),
).fetchall()]
def sleep_by_night(conn, days=14):
"""Total minutes per stage grouped by the calendar day the sleep segment ends
(a night that crosses midnight is attributed to the wake day)."""
return [dict(r) for r in conn.execute(
"""SELECT date(end_time) AS night, stage_name,
SUM((end_ns - start_ns)/60000000000.0) AS minutes
FROM sleep_segments WHERE end_time >= datetime('now', ?)
GROUP BY night, stage_name ORDER BY night DESC""",
(f"-{int(days)} days",),
).fetchall()]
def summary(conn):
"""Compact health snapshot for Adolf: row counts, coverage, freshness."""
out = {}
for name, q in (
("data_points", "SELECT COUNT(*) c FROM data_points"),
("sessions", "SELECT COUNT(*) c FROM sessions"),
("sleep_segments", "SELECT COUNT(*) c FROM sleep_segments"),
):
out[name] = conn.execute(q).fetchone()["c"]
out["metrics"] = [r["metric"] for r in conn.execute(
"SELECT DISTINCT metric FROM data_points ORDER BY metric").fetchall()]
span = conn.execute(
"SELECT MIN(start_time) a, MAX(start_time) b FROM data_points").fetchone()
out["coverage"] = {"earliest": span["a"], "latest": span["b"]}
out["sync_state"] = [dict(r) for r in conn.execute(
"SELECT stream_key, last_run_at, last_status FROM sync_state").fetchall()]
last = conn.execute(
"SELECT started_at, finished_at, status, points_upserted FROM ingest_runs "
"ORDER BY id DESC LIMIT 1").fetchone()
out["last_run"] = dict(last) if last else None
return out