Files
AgapHost/mood/README.md
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

270 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# mood — local archive of mood.alogins.net
Local SQLite copy of mood entries logged at **mood.alogins.net**, plus simple
reports and a cross-source correlation hook. Kanboard **Adolf #107** (data-pipeline
half only — see "Scope" below).
Status: **built and proven against the real live data** (28 real entries pulled
and queried successfully). Not yet deployed as a running service — `docker
compose up -d` is a one-line handover, see "Deploy".
---
## Scope of this build
Task #107 has four parts. This directory implements **1, 3, and 4 only**:
1. ✅ Investigate whether mood.alogins.net has an API/export — done, see below.
3. ✅ Regular ingestion into local SQLite storage on Agap — done, this service.
4. ✅ Simple reports/correlations over the stored data — done, `query` CLI.
**2 is explicitly NOT built here**: "proactive Matrix/Telegram reminder" is an
*outward-facing, scheduled* message to the user. That capability belongs to
the proactive-cadence framework (Kanboard **Adolf #124**), which is currently
**parked, tagged `blocked`, awaiting a human decision** on whether to enable
its cron at all (a sibling run already had to back out an unauthorized live
crontab install — see #124 comments). Wiring a second scheduled outward
message before that decision lands would repeat the same mistake.
**Follow-up task to create**: "Wire the mood.alogins.net proactive reminder"
*depends on #124's cron being authorized*. Once #124 is resolved, adding
this reminder is small: a text-only Backlog-card-generation step reusing
whatever executor #124 lands on, with the message
`Как день? Запиши в mood.alogins.net`. See "Ready-to-hand-over reminder"
below for a schedule a human can enable manually right now if they don't want
to wait for #124.
---
## 1. Investigation: does mood.alogins.net have an API?
**mood.alogins.net is not a third-party tracker** — it's a small self-built
Flask app already running on Agap:
- Source: `/home/alvis/moodtracker/app.py` (+ `templates/`)
- Container: `moodtracker` (compose at `/home/alvis/moodtracker/docker-compose.yml`)
- Caddy: `mood.alogins.net { reverse_proxy localhost:5177 }` (`/etc/caddy/Caddyfile`)
- DB: SQLite at `/home/alvis/moodtracker/data/mood.db`, one table `entries`
(`id, ts, mood, tags, note, affirmation`), mood on a 15 scale, tags a JSON
array of free-text strings, `ts` ISO8601 UTC.
**It does have a JSON API**, but it's session-cookie gated, not token-based:
- `POST /login` (form `username`/`password` = `AUTH_USER`/`AUTH_PASS` env vars,
currently plaintext in its own `docker-compose.yml` — pre-existing, not
something this task introduced) → sets a Flask session cookie.
- `GET /api/history?limit=N`, `POST /api/log`, `DELETE /api/entry/<id>` — all
require that session cookie (`@require_auth`); no HTTP token/API-key.
**Chosen ingestion path: read the SQLite file directly, not the HTTP API.**
`data/mood.db` is host-readable (`644`, owned by `root:root`, world-read bit
set) — no credential needed. This is strictly more robust than replicating a
cookie-login flow: it survives moodtracker adding/changing auth, needs no
secret in this service at all, and is read-only by construction (bind-mounted
`:ro`), so it can never corrupt or lock the live app's database.
---
## 3. Ingestion architecture
```
moodtracker's own SQLite file ──(read-only bind mount)──► src/mood_source.py
/data/mood.db │
src/sync.py
(cursor + idempotent upsert)
local archive: mood_entries
/mnt/dbs/mood/mood_archive.sqlite
src/cli.py query (reports)
```
Mirrors the sibling `googlefit` service's shape (same author idiom, see
`agap_git/googlefit/`):
- `schema.sql``mood_entries` (PK `source, source_id`), `sync_state`
(per-stream cursor), `ingest_runs` (audit log).
- `src/config.py` — paths + tuning, no credentials (none needed).
- `src/mood_source.py` — read-only reader for moodtracker's SQLite file.
- `src/store.py` — schema init, idempotent UPSERTs, read/report queries.
- `src/sync.py` — cursor-based incremental sync, isolated failure handling.
- `src/cli.py``init-db | sync | query`.
### Why SQLite, not InfluxDB
Same reasoning as `googlefit`: single-user, a handful of manually-logged
entries a week — trivial volume. Agap storage doctrine is SQLite-first. No
extra always-on TSDB service for ~30 rows/month of data.
### Idempotency / cursor
`sync_state.last_synced_id` is the high-water mark on moodtracker's own
`entries.id`. Each run re-checks the last `MOOD_OVERLAP_ROWS` (default 3)
already-synced ids too, as a cheap safety net — moodtracker currently has no
edit endpoint (only insert + delete), so this is mostly redundant today but
costs nothing.
**Deleted-upstream entries are kept.** If an entry is deleted via moodtracker's
`DELETE /api/entry/<id>`, this archive does not remove its copy — it's an
append-only journal by design, so history survives accidental or intentional
deletes in the live app.
### Proven against real data
```
$ python -m src.cli sync --once
{"synced": {"entries": 28, "errors": []}}
$ python -m src.cli query summary
{"entries": 28, "coverage": {"earliest": "2026-05-18T04:44:34...", "latest": "2026-07-07T05:49:34..."}, "avg_mood_all_time": 3.64, ...}
```
Re-running `sync --once` twice more produced **zero row growth** (idempotent).
The source file's mtime was unchanged after every sync run (proves read-only).
Also verified end-to-end through the built Docker image (`docker build` +
`docker run --rm ... sync --once` against the real, live-mounted
`/home/alvis/moodtracker/data`), then removed the test image/container —
nothing was left running.
### Test suite
```
$ python -m pytest tests/ -q
............ [100%]
12 passed in 0.11s
```
Covers: upsert idempotency, conflict updates, sync-cursor high-water-mark
behavior, a mock-moodtracker-schema DB driven through `run_sync` (first run,
idempotent re-run, incremental pickup of a newly-inserted row, error handling
when the source is missing, and read-only-ness), plus the report/correlation
math below.
---
## 4. Reports / correlations
```bash
python -m src.cli query summary # counts, coverage, freshness
python -m src.cli query entries --days 30 # raw recent entries
python -m src.cli query daily --days 30 # avg mood + entry count per day
python -m src.cli query tags --days 90 # avg mood per tag (min 2 occurrences)
python -m src.cli query correlate <csv> --days 90
```
`daily` and `tags` are the "mood over time" and "which tags coincide with
low/high mood" reports from point 4. Real output against the live data
(tags, 90-day window): lowest avg mood tags were `exhausted` (1.0, n=2),
`depressed` (2.0, n=4); highest were `happy` (5.0, n=4), `energetic` (4.6, n=5).
### Correlation hook (cross-source, not wired)
`query correlate <csv_path>` computes a Pearson `r` between the daily average
mood and an arbitrary external daily series supplied as a plain
`day,value` CSV (`YYYY-MM-DD,float`). This is a deliberate seam: it takes
**data**, not a live connection to another service's DB or container, so
wiring a real second source later (e.g. `googlefit query metric
heart_rate_avg` or sleep minutes, once that service is deployed) is a one-line
change — build the CSV/dict from that service's own read-only query CLI. This
build does **not** reach into `googlefit` or any other service's DB, per the
task's "leave hooks, don't wire other services" instruction.
Tested with a synthetic series (`tests/test_store.py::test_correlate_with_series`,
near-perfect correlation asserted `r > 0.99`) and manually against the real
mood data with a hand-built "hours slept" CSV (`r = 0.933`, n=5, small sample —
illustrative only, not a real finding).
---
## Deploy
```bash
mkdir -p /mnt/dbs/mood # (needs sudo — /mnt/dbs is root-owned; see googlefit precedent)
cd /home/alvis/agap_git/mood
docker compose up -d --build
```
The container loops `mood sync` every `MOOD_SYNC_INTERVAL_SECONDS` (default
3600s / hourly — mood entries are logged manually, hourly polling of a local
file is effectively free and gives fresh reports without any real cost).
One-off / cron alternative (no long-lived container):
```bash
docker compose run --rm mood-archive python -m src.cli sync --once
```
**This was not started as a live service in this build** — only proven via
`docker build` + `docker run --rm ... --once` against a scratch data
directory, then torn down. Bringing up the persistent `restart: unless-stopped`
container is a one-line `docker compose up -d --build` for a human/operator to run.
---
## Read tool for Adolf
```bash
docker compose run --rm mood-archive python -m src.cli query summary
docker compose run --rm mood-archive python -m src.cli query daily --days 14
docker compose run --rm mood-archive python -m src.cli query tags --days 90
```
JSON on stdout, read-only, no credentials.
Follow-up (adjacent, not in this task, same idiom as `googlefit`'s README):
promote `query` to a native `agap-mcp` tool once that server's active edit
window (sibling task touching `shared-mcp.json`/`openclaw.json`) is clear.
---
## Ready-to-hand-over reminder (point 2, NOT installed)
Per the scope note above, the proactive reminder is intentionally not built
or scheduled here. If a human wants it live **without** waiting for #124's
cron decision, here is a self-contained one-liner using the existing Telegram
bot credentials already in Vaultwarden (`TELEGRAM_BOT_TOKEN`,
`TELEGRAM_CHAT_ID`) — nothing new to build, nothing in this repo depends on
it:
```bash
BW=/home/alvis/bin/bw
SESSION=$(env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
NODE_TLS_REJECT_UNAUTHORIZED=0 $BW unlock "$BW_PASSWORD" --raw 2>/dev/null)
BOT=$(env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
NODE_TLS_REJECT_UNAUTHORIZED=0 $BW get password "TELEGRAM_BOT_TOKEN" --session "$SESSION" 2>/dev/null)
CHAT=$(env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
NODE_TLS_REJECT_UNAUTHORIZED=0 $BW get password "TELEGRAM_CHAT_ID" --session "$SESSION" 2>/dev/null)
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
curl -s -X POST "https://api.telegram.org/bot${BOT}/sendMessage" \
-d "chat_id=${CHAT}" -d "text=Как день? Запиши в mood.alogins.net"
```
Proposed cron (a human adds this — **not installed by this task**, per the
"never install an unattended cron on a live target" rule):
```
0 21 * * * /home/alvis/agap_git/mood/scripts/remind.sh # hypothetical path if built
```
No `scripts/remind.sh` exists yet — the command above is the full logic; if
approved, wrapping it in a script + crontab line is a ~2-minute follow-up, but
it is outward-facing (sends a message unattended) so it needs the same
explicit human go-ahead #124 is waiting on, not a unilateral install by an
agent.
---
## Files
```
mood/
├── README.md
├── schema.sql
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── .env.example
├── .gitignore
├── src/
│ ├── config.py
│ ├── mood_source.py # read-only reader for moodtracker's SQLite file
│ ├── store.py # schema, upserts, reports, correlation hook
│ ├── sync.py # cursor-based incremental sync
│ └── cli.py # init-db | sync | query
└── tests/
├── test_store.py
└── test_sync.py # drives run_sync against a mock moodtracker DB
```
Nothing in this directory is committed to git — `agap_git` is a git repo but
no `git add`/`git commit` was run for this task.