feat(skills): add datastore skill (DuckDB engine, JSONL partitioning, Garmin mirror, and view renderers)

This commit is contained in:
Moritz Graf
2026-10-06 18:17:52 +02:00
parent 2bde04617f
commit 0489ef7d62
8 changed files with 2987 additions and 0 deletions
+982
View File
@@ -0,0 +1,982 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "duckdb>=1.1.0",
# "python-ulid>=3.0.0",
# "pytz>=2024.1",
# ]
# ///
"""
OpenClaw Datastore — Structured Personal Data Layer (DuckDB + JSONL in Git)
Provides deterministic math, validation, interval handling, spatial queries,
and JSONL file persistence for personal nutrition, baby sleep, sports, recipes, and places.
Exposes both a CLI and a stdio JSON-RPC MCP server.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from zoneinfo import ZoneInfo
import duckdb
def generate_ulid() -> str:
try:
from ulid import ULID
return str(ULID())
except TypeError:
import ulid
return str(ulid.new())
except ImportError:
import uuid
return str(uuid.uuid4())
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def find_data_dir() -> Path:
"""Discovers the knowledge-wiki data directory."""
if os.environ.get("DATASTORE_DIR"):
return Path(os.environ["DATASTORE_DIR"]).resolve()
candidates = [
Path.cwd() / "knowledge-wiki" / "data",
Path.cwd() / "data",
Path.home() / "src" / "infrapuzzle" / "knowledge-wiki" / "data",
Path.home() / ".openclaw" / "wiki" / "main" / "data",
Path("/home/node/.openclaw/wiki/main/data"),
]
for c in candidates:
if c.is_dir() and (c / "_meta.json").exists():
return c.resolve()
# Fallback to creating a local data directory if needed
default_dir = Path.cwd() / "knowledge-wiki" / "data"
default_dir.mkdir(parents=True, exist_ok=True)
return default_dir.resolve()
class DatastoreEngine:
def __init__(self, data_dir: Optional[Path] = None):
self.data_dir = data_dir or find_data_dir()
self.con = duckdb.connect(":memory:")
self.has_spatial_extension = False
self._init_schemas()
self._load_all_data()
def _init_schemas(self):
"""Initializes tables, views, and spatial support in DuckDB."""
# Timezone support via icu extension
try:
self.con.execute("INSTALL icu; LOAD icu;")
self.con.execute("SET TimeZone = 'Europe/Berlin';")
except Exception:
pass
# Spatial support: try extension, fallback to pure-SQL Haversine macro
try:
self.con.execute("INSTALL spatial; LOAD spatial;")
self.has_spatial_extension = True
except Exception:
self.has_spatial_extension = False
# Fallback pure-SQL distance macro (returns meters)
self.con.execute("""
CREATE MACRO IF NOT EXISTS distance_m(lat1, lon1, lat2, lon2) AS
6371000 * 2 * asin(sqrt(
sin(radians(lat2 - lat1) / 2) ^ 2 +
cos(radians(lat1)) * cos(radians(lat2)) * sin(radians(lon2 - lon1) / 2) ^ 2
));
""")
schema_dir = self.data_dir / "_schema"
if schema_dir.is_dir():
for f in ["envelope.sql", "entities.sql", "views.sql"]:
sql_file = schema_dir / f
if sql_file.is_file():
sql = sql_file.read_text(encoding="utf-8")
self.con.execute(sql)
else:
# Inline fallback schema if _schema is missing
self._create_inline_schema()
def _create_inline_schema(self):
self.con.execute("""
CREATE TABLE IF NOT EXISTS events (
id VARCHAR PRIMARY KEY,
kind VARCHAR NOT NULL,
subject VARCHAR NOT NULL,
actor VARCHAR NOT NULL,
start TIMESTAMPTZ NOT NULL,
"end" TIMESTAMPTZ,
estimated BOOLEAN DEFAULT false,
meal VARCHAR,
note VARCHAR,
payload JSON NOT NULL,
source_ref VARCHAR,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
CHECK ("end" IS NULL OR "end" >= start)
);
CREATE TABLE IF NOT EXISTS people (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
role VARCHAR,
birthdate DATE,
notes VARCHAR
);
CREATE TABLE IF NOT EXISTS foods (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
brand VARCHAR,
category VARCHAR,
per_100 JSON,
density_g_per_ml DOUBLE,
ingredients_text VARCHAR,
barcode VARCHAR,
source VARCHAR,
description VARCHAR
);
CREATE TABLE IF NOT EXISTS recipes (
id VARCHAR PRIMARY KEY,
title VARCHAR NOT NULL,
servings INTEGER DEFAULT 1,
ingredients JSON,
estimated_delta JSON,
devices VARCHAR[],
tags VARCHAR[],
documents VARCHAR[],
description VARCHAR
);
CREATE TABLE IF NOT EXISTS places (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
category VARCHAR NOT NULL,
lat DOUBLE NOT NULL,
lon DOUBLE NOT NULL,
address VARCHAR,
google_maps_url VARCHAR,
tags VARCHAR[],
attributes JSON,
documents VARCHAR[],
notes VARCHAR
);
CREATE TABLE IF NOT EXISTS garmin_daily (
calendarDate DATE PRIMARY KEY,
totalKilocalories INTEGER,
activeKilocalories INTEGER,
bmrKilocalories INTEGER,
totalSteps INTEGER,
restingHeartRate INTEGER,
raw JSON
);
""")
def _load_all_data(self):
"""Loads all existing JSONL files from entities/, events/, and garmin/ into DuckDB."""
# 1. Load entities
entities_dir = self.data_dir / "entities"
if entities_dir.is_dir():
for f in entities_dir.glob("*.jsonl"):
collection = f.stem
if collection in ["people", "foods", "recipes", "places"]:
self._load_jsonl_file(f, collection)
# 2. Load events
events_dir = self.data_dir / "events"
if events_dir.is_dir():
for f in events_dir.glob("**/*.jsonl"):
self._load_jsonl_file(f, "events")
# 3. Load garmin daily
garmin_daily_dir = self.data_dir / "garmin" / "daily"
if garmin_daily_dir.is_dir():
for f in garmin_daily_dir.glob("*.jsonl"):
self._load_garmin_daily_file(f)
def _load_garmin_daily_file(self, file_path: Path):
"""Reads daily summaries from JSONL into garmin_daily table."""
if not file_path.is_file() or file_path.stat().st_size == 0:
return
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
cal_date = record.get("calendarDate")
if not cal_date:
continue
total_kcal = record.get("totalKilocalories")
active_kcal = record.get("activeKilocalories")
bmr_kcal = record.get("bmrKilocalories")
steps = record.get("totalSteps")
rhr = record.get("restingHeartRate")
raw = json.dumps(record, ensure_ascii=False)
self.con.execute(
"""
INSERT OR REPLACE INTO garmin_daily
(calendarDate, totalKilocalories, activeKilocalories, bmrKilocalories, totalSteps, restingHeartRate, raw)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[cal_date, total_kcal, active_kcal, bmr_kcal, steps, rhr, raw],
)
except Exception as e:
logger.warning(f"Error loading garmin record from {file_path}: {e}")
def _load_jsonl_file(self, file_path: Path, table_name: str):
"""Reads lines from JSONL and inserts into target table."""
if not file_path.is_file() or file_path.stat().st_size == 0:
return
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
columns = list(record.keys())
placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join([f'"{c}"' for c in columns])
values = [
json.dumps(record[c]) if isinstance(record[c], (dict, list)) else record[c]
for c in columns
]
self.con.execute(
f'INSERT OR REPLACE INTO "{table_name}" ({col_names}) VALUES ({placeholders})',
values,
)
except Exception as e:
# Ignore comment lines or malformed lines with warning
pass
# --------------------------------------------------------------------------
# Event Operations
# --------------------------------------------------------------------------
def add_event(
self,
kind: str,
subject: str,
actor: str,
payload: Dict[str, Any],
start: Optional[str] = None,
end: Optional[str] = None,
estimated: bool = False,
meal: Optional[str] = None,
note: Optional[str] = None,
source_ref: Optional[str] = None,
) -> Dict[str, Any]:
"""Adds a time-series event, saves to JSONL partition, and returns confirmation."""
event_id = generate_ulid()
now_dt = datetime.datetime.now(datetime.timezone.utc)
now_iso = now_dt.isoformat()
# Parse or default start time
if not start:
start_dt = now_dt.astimezone(BERLIN_TZ)
start_iso = start_dt.isoformat()
else:
start_dt = datetime.datetime.fromisoformat(start)
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=BERLIN_TZ)
start_iso = start_dt.isoformat()
end_iso = None
if end:
end_dt = datetime.datetime.fromisoformat(end)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
if end_dt < start_dt:
raise ValueError(f"End time {end} cannot be earlier than start time {start}")
end_iso = end_dt.isoformat()
# Validation for open intervals in sleep
if kind == "sleep.session" and end_iso is None:
# Check if there is already an open session for this subject
open_sessions = self.con.execute(
"SELECT id, start FROM events WHERE kind = 'sleep.session' AND subject = ? AND \"end\" IS NULL AND deleted_at IS NULL",
[subject],
).fetchall()
if open_sessions:
raise ValueError(
f"Subject '{subject}' already has an open sleep session ({open_sessions[0][0]} started at {open_sessions[0][1]}). Please stop it first."
)
record = {
"id": event_id,
"kind": kind,
"subject": subject,
"actor": actor,
"start": start_iso,
"end": end_iso,
"estimated": estimated,
"meal": meal,
"note": note,
"payload": payload,
"source_ref": source_ref,
"created_at": now_iso,
"updated_at": None,
"deleted_at": None,
}
# Insert into in-memory DuckDB
self.con.execute(
"""
INSERT INTO events (id, kind, subject, actor, start, "end", estimated, meal, note, payload, source_ref, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
event_id,
kind,
subject,
actor,
start_iso,
end_iso,
estimated,
meal,
note,
json.dumps(payload),
source_ref,
now_iso,
None,
None,
],
)
# Write to partitioned JSONL file
# Partition path: events/<domain>/<YYYY-MM>.jsonl
domain = kind.split(".")[0]
month_str = start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m")
domain_dir = self.data_dir / "events" / domain
domain_dir.mkdir(parents=True, exist_ok=True)
partition_file = domain_dir / f"{month_str}.jsonl"
with open(partition_file, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# Sort partition deterministically by start, id
self._sort_jsonl_file(partition_file, key_fn=lambda x: (x.get("start", ""), x.get("id", "")))
# Fetch computed feedback
feedback = self._get_event_feedback(kind, subject, start_dt.date())
return {
"status": "success",
"event": record,
"feedback": feedback,
}
def stop_event(
self,
kind: str,
subject: str,
end_time: Optional[str] = None,
actor: Optional[str] = None,
) -> Dict[str, Any]:
"""Finds the open event (end IS NULL) for kind and subject, and closes it."""
row = self.con.execute(
"""
SELECT id, start FROM events
WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
ORDER BY start DESC LIMIT 1
""",
[kind, subject],
).fetchone()
if not row:
raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'")
event_id, start_str = row[0], str(row[1])
now_dt = datetime.datetime.now(datetime.timezone.utc)
if end_time:
end_dt = datetime.datetime.fromisoformat(end_time)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=BERLIN_TZ)
else:
end_dt = now_dt.astimezone(BERLIN_TZ)
end_iso = end_dt.isoformat()
now_iso = now_dt.isoformat()
# Update in DuckDB
self.con.execute(
"""
UPDATE events
SET "end" = ?, updated_at = ?
WHERE id = ?
""",
[end_iso, now_iso, event_id],
)
# Update in JSONL
domain = kind.split(".")[0]
self._update_jsonl_record(domain, event_id, {"end": end_iso, "updated_at": now_iso})
# Calculate duration
start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
duration_minutes = int((end_dt.astimezone(BERLIN_TZ) - start_dt.astimezone(BERLIN_TZ)).total_seconds() / 60)
return {
"status": "success",
"event_id": event_id,
"kind": kind,
"subject": subject,
"start": start_str,
"end": end_iso,
"duration_minutes": duration_minutes,
"duration_formatted": f"{duration_minutes // 60} h {duration_minutes % 60} min"
if duration_minutes >= 60
else f"{duration_minutes} min",
}
def delete_event(self, event_id: str) -> Dict[str, Any]:
"""Soft-deletes an event."""
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
row = self.con.execute("SELECT kind FROM events WHERE id = ?", [event_id]).fetchone()
if not row:
raise ValueError(f"Event with id '{event_id}' not found.")
kind = row[0]
domain = kind.split(".")[0]
self.con.execute("UPDATE events SET deleted_at = ? WHERE id = ?", [now_iso, event_id])
self._update_jsonl_record(domain, event_id, {"deleted_at": now_iso})
return {"status": "success", "deleted_event_id": event_id}
# --------------------------------------------------------------------------
# Entity Operations
# --------------------------------------------------------------------------
def put_entity(self, collection: str, record: Dict[str, Any]) -> Dict[str, Any]:
"""Upserts an entity into entities/<collection>.jsonl."""
if collection not in ["foods", "recipes", "places", "people"]:
raise ValueError(f"Unknown entity collection: {collection}")
entity_id = record.get("id")
if not entity_id:
raise ValueError("Entity record must have an 'id' field.")
entities_dir = self.data_dir / "entities"
entities_dir.mkdir(parents=True, exist_ok=True)
target_file = entities_dir / f"{collection}.jsonl"
# Read existing, update or append
existing_records = []
replaced = False
if target_file.is_file():
with open(target_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
if r.get("id") == entity_id:
existing_records.append(record)
replaced = True
else:
existing_records.append(r)
except Exception:
pass
if not replaced:
existing_records.append(record)
# Sort by id
existing_records.sort(key=lambda x: str(x.get("id", "")))
with open(target_file, "w", encoding="utf-8") as f:
for r in existing_records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
# Reload into DuckDB
columns = list(record.keys())
placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join([f'"{c}"' for c in columns])
values = [
json.dumps(record[c]) if isinstance(record[c], (dict, list)) else record[c]
for c in columns
]
self.con.execute(
f'INSERT OR REPLACE INTO "{collection}" ({col_names}) VALUES ({placeholders})',
values,
)
return {"status": "success", "collection": collection, "id": entity_id}
# --------------------------------------------------------------------------
# Queries & Feedback
# --------------------------------------------------------------------------
def query(self, sql: str) -> List[Dict[str, Any]]:
"""Executes read-only SQL query and returns list of dictionaries."""
clean_sql = sql.strip()
# Basic safeguard against destructive statements in read query
disallowed = ["DROP ", "ALTER ", "TRUNCATE ", "DELETE FROM", "UPDATE "]
for d in disallowed:
if clean_sql.upper().startswith(d):
raise ValueError(f"Direct {d.strip()} queries not permitted via query(). Use dedicated methods.")
cursor = self.con.execute(clean_sql)
cols = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
results = []
for row in rows:
record = {}
for col, val in zip(cols, row):
if isinstance(val, (datetime.datetime, datetime.date)):
record[col] = val.isoformat()
else:
record[col] = val
results.append(record)
return results
def get_summary(self, subject: str, date_str: Optional[str] = None) -> Dict[str, Any]:
"""Returns daily summary for subject (nutrition, sleep, deficit)."""
target_date = date_str or datetime.datetime.now(BERLIN_TZ).strftime("%Y-%m-%d")
res: Dict[str, Any] = {"subject": subject, "date": target_date}
# 1. Nutrition summary
nut = self.query(f"SELECT * FROM v_nutrition_daily WHERE subject = '{subject}' AND log_date = '{target_date}'")
if nut:
res["nutrition"] = nut[0]
else:
res["nutrition"] = {
"total_protein_g": 0.0,
"total_carbs_g": 0.0,
"total_sugar_g": 0.0,
"total_fat_g": 0.0,
"total_fiber_g": 0.0,
"total_salt_g": 0.0,
"total_kcal": 0,
"item_count": 0,
}
# 2. Sleep summary
slp = self.query(f"SELECT * FROM v_sleep_daily WHERE subject = '{subject}' AND log_date = '{target_date}'")
if slp:
res["sleep"] = slp[0]
# 3. Active open sleeps
open_sleep = self.query(
f"SELECT id, start, local_start, sleep_type, location FROM v_sleep WHERE subject = '{subject}' AND is_open = true"
)
if open_sleep:
res["open_sleep"] = open_sleep[0]
return res
def find_places_near(
self,
lat: float,
lon: float,
radius_m: float = 1000.0,
category: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Radius search for places using spatial extension or Haversine fallback."""
cat_filter = f"AND category = '{category}'" if category else ""
if self.has_spatial_extension:
sql = f"""
SELECT id, name, category, lat, lon, address, google_maps_url, tags, notes, documents,
ROUND(ST_Distance_Spheroid(ST_Point(lon, lat), ST_Point({lon}, {lat})), 1) AS distance_meters
FROM places
WHERE ST_Distance_Spheroid(ST_Point(lon, lat), ST_Point({lon}, {lat})) <= {radius_m}
{cat_filter}
ORDER BY distance_meters ASC
"""
else:
sql = f"""
SELECT id, name, category, lat, lon, address, google_maps_url, tags, notes, documents,
ROUND(distance_m({lat}, {lon}, lat, lon), 1) AS distance_meters
FROM places
WHERE distance_m({lat}, {lon}, lat, lon) <= {radius_m}
{cat_filter}
ORDER BY distance_meters ASC
"""
return self.query(sql)
def _get_event_feedback(self, kind: str, subject: str, target_date: datetime.date) -> Dict[str, Any]:
"""Generates compact running status feedback after an insert."""
date_str = target_date.strftime("%Y-%m-%d")
if kind.startswith("nutrition"):
daily = self.con.execute(
"SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_kcal FROM v_nutrition_daily WHERE subject = ? AND log_date = ?",
[subject, date_str],
).fetchone()
if daily:
return {
"day_total_formatted": f"Tag bisher ({date_str}): ~{daily[0]} g Eiweiß · ~{daily[1]} g KH (davon ~{daily[2]} g Zucker) · ~{daily[3]} g Fett · ~{int(daily[4])} kcal",
"protein_g": daily[0],
"carbs_g": daily[1],
"kcal": daily[4],
}
elif kind.startswith("sleep"):
daily = self.con.execute(
"SELECT total_sleep_hours, nap_count, night_sleep_minutes FROM v_sleep_daily WHERE subject = ? AND log_date = ?",
[subject, date_str],
).fetchone()
if daily:
return {
"day_total_formatted": f"Schlaf ({date_str}): {daily[0]} h gesamt ({daily[1]} Schläfchen, {daily[2]} min Nacht)",
"total_hours": daily[0],
"nap_count": daily[1],
}
return {}
# --------------------------------------------------------------------------
# Internal Helpers
# --------------------------------------------------------------------------
def _sort_jsonl_file(self, file_path: Path, key_fn):
if not file_path.is_file():
return
records = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
records.append(json.loads(line))
except Exception:
pass
records.sort(key=key_fn)
with open(file_path, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
def _update_jsonl_record(self, domain: str, event_id: str, updates: Dict[str, Any]):
"""Finds and updates a record in events/<domain>/*.jsonl."""
domain_dir = self.data_dir / "events" / domain
if not domain_dir.is_dir():
return
for p_file in domain_dir.glob("*.jsonl"):
records = []
found = False
with open(p_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
if r.get("id") == event_id:
r.update(updates)
found = True
records.append(r)
except Exception:
pass
if found:
with open(p_file, "w", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
return
# ==============================================================================
# JSON-RPC MCP Server Protocol
# ==============================================================================
def run_mcp_server():
"""Runs standard JSON-RPC stdio MCP server for OpenClaw."""
engine = DatastoreEngine()
TOOLS = [
{
"name": "datastore_add_event",
"description": "Log an event into the structured datastore (e.g. nutrition.intake, sleep.session, feeding.bottle). Computes all macros and totals deterministically.",
"inputSchema": {
"type": "object",
"properties": {
"kind": {"type": "string", "description": "e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle'"},
"subject": {"type": "string", "description": "'moritz', 'amelie', or 'mila'"},
"actor": {"type": "string", "description": "'agent:koch', 'agent:hebamme', 'moritz', 'amelie'"},
"payload": {"type": "object", "description": "Detailed payload matching the kind schema"},
"start": {"type": "string", "description": "ISO 8601 timestamp (optional, defaults to now)"},
"end": {"type": "string", "description": "ISO 8601 timestamp (optional for intervals)"},
"estimated": {"type": "boolean", "description": "True if portions or macros are estimated"},
"meal": {"type": "string", "description": "Optional meal grouping (e.g. 'Frühstück', 'Mittagessen')"},
"note": {"type": "string", "description": "Free text notes or context"},
},
"required": ["kind", "subject", "actor", "payload"],
},
},
{
"name": "datastore_stop_event",
"description": "Closes an open event interval (e.g. Mila wakes up -> closes open sleep.session).",
"inputSchema": {
"type": "object",
"properties": {
"kind": {"type": "string", "description": "e.g. 'sleep.session'"},
"subject": {"type": "string", "description": "'mila', 'moritz', 'amelie'"},
"end_time": {"type": "string", "description": "ISO timestamp (optional, defaults to now)"},
},
"required": ["kind", "subject"],
},
},
{
"name": "datastore_get_summary",
"description": "Fetch daily nutrition, sleep, and status totals computed deterministically by DuckDB.",
"inputSchema": {
"type": "object",
"properties": {
"subject": {"type": "string", "description": "'moritz', 'mila', or 'amelie'"},
"date": {"type": "string", "description": "YYYY-MM-DD (defaults to today)"},
},
"required": ["subject"],
},
},
{
"name": "datastore_query",
"description": "Execute a read-only DuckDB SQL query against the datastore (events, v_nutrition, v_sleep, places, foods, etc.).",
"inputSchema": {
"type": "object",
"properties": {
"sql": {"type": "string", "description": "SQL SELECT query to execute"},
},
"required": ["sql"],
},
},
{
"name": "datastore_find_places_near",
"description": "Search for points of interest (beer gardens, playgrounds, stores) within a given radius in meters.",
"inputSchema": {
"type": "object",
"properties": {
"lat": {"type": "number", "description": "Latitude (e.g. 49.0205 for Grabengasse)"},
"lon": {"type": "number", "description": "Longitude (e.g. 12.0975 for Grabengasse)"},
"radius_m": {"type": "number", "description": "Search radius in meters (default 1000)"},
"category": {"type": "string", "description": "Optional category filter (e.g. 'biergarten')"},
},
"required": ["lat", "lon"],
},
},
{
"name": "datastore_put_entity",
"description": "Upsert an entity record into foods, recipes, places, or people.",
"inputSchema": {
"type": "object",
"properties": {
"collection": {"type": "string", "enum": ["foods", "recipes", "places", "people"]},
"record": {"type": "object", "description": "Entity record matching collection schema"},
},
"required": ["collection", "record"],
},
},
]
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception:
continue
method = req.get("method")
msg_id = req.get("id")
if method == "initialize":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"serverInfo": {"name": "openclaw-datastore", "version": "1.0.0"},
"capabilities": {"tools": {}},
},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/list":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {"tools": TOOLS},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/call":
params = req.get("params", {})
name = params.get("name")
args = params.get("arguments", {})
try:
if name == "datastore_add_event":
data = engine.add_event(
kind=args["kind"],
subject=args["subject"],
actor=args.get("actor", "agent"),
payload=args["payload"],
start=args.get("start"),
end=args.get("end"),
estimated=args.get("estimated", False),
meal=args.get("meal"),
note=args.get("note"),
source_ref=args.get("source_ref"),
)
elif name == "datastore_stop_event":
data = engine.stop_event(
kind=args["kind"],
subject=args["subject"],
end_time=args.get("end_time"),
)
elif name == "datastore_get_summary":
data = engine.get_summary(subject=args["subject"], date_str=args.get("date"))
elif name == "datastore_query":
data = engine.query(sql=args["sql"])
elif name == "datastore_find_places_near":
data = engine.find_places_near(
lat=args["lat"],
lon=args["lon"],
radius_m=args.get("radius_m", 1000.0),
category=args.get("category"),
)
elif name == "datastore_put_entity":
data = engine.put_entity(
collection=args["collection"],
record=args["record"],
)
else:
raise ValueError(f"Unknown tool: {name}")
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [{"type": "text", "text": json.dumps(data, indent=2, ensure_ascii=False)}]
},
}
except Exception as e:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"isError": True,
"content": [{"type": "text", "text": f"Error executing {name}: {str(e)}"}],
},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif msg_id is not None:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
# ==============================================================================
# CLI Entrypoint
# ==============================================================================
def run_cli():
parser = argparse.ArgumentParser(description="OpenClaw Datastore CLI")
subparsers = parser.add_subparsers(dest="command")
# Add Event
p_add = subparsers.add_parser("add", help="Add an event")
p_add.add_argument("kind", help="e.g. nutrition.intake, sleep.session")
p_add.add_argument("--subject", required=True, help="moritz, mila, amelie")
p_add.add_argument("--actor", default="cli", help="Reporter identifier")
p_add.add_argument("--payload", required=True, help="JSON payload string")
p_add.add_argument("--start", help="ISO start time")
p_add.add_argument("--end", help="ISO end time")
p_add.add_argument("--meal", help="Meal grouping")
p_add.add_argument("--note", help="Free-form note")
p_add.add_argument("--estimated", action="store_true", help="Flag as estimated")
# Stop Event
p_stop = subparsers.add_parser("stop", help="Stop an open interval event")
p_stop.add_argument("kind", help="e.g. sleep.session")
p_stop.add_argument("--subject", required=True, help="mila, moritz")
p_stop.add_argument("--end", help="ISO end time (defaults to now)")
# Query
p_query = subparsers.add_parser("query", help="Execute read-only SQL query")
p_query.add_argument("sql", help="SQL string")
# Summary
p_sum = subparsers.add_parser("summary", help="Get daily summary")
p_sum.add_argument("--subject", required=True, help="moritz, mila")
p_sum.add_argument("--date", help="YYYY-MM-DD")
# Places
p_places = subparsers.add_parser("places", help="Radius search for places")
p_places.add_argument("--lat", type=float, required=True)
p_places.add_argument("--lon", type=float, required=True)
p_places.add_argument("--radius", type=float, default=1000.0)
p_places.add_argument("--category")
args = parser.parse_args()
engine = DatastoreEngine()
if args.command == "add":
payload = json.loads(args.payload)
res = engine.add_event(
kind=args.kind,
subject=args.subject,
actor=args.actor,
payload=payload,
start=args.start,
end=args.end,
meal=args.meal,
note=args.note,
estimated=args.estimated,
)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "stop":
res = engine.stop_event(kind=args.kind, subject=args.subject, end_time=args.end)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "query":
res = engine.query(args.sql)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "summary":
res = engine.get_summary(subject=args.subject, date_str=args.date)
print(json.dumps(res, indent=2, ensure_ascii=False))
elif args.command == "places":
res = engine.find_places_near(
lat=args.lat,
lon=args.lon,
radius_m=args.radius,
category=args.category,
)
print(json.dumps(res, indent=2, ensure_ascii=False))
else:
parser.print_help()
if __name__ == "__main__":
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
run_mcp_server()
else:
run_cli()
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
Garmin Sync: Deterministic mirror of Garmin Connect data into git-backed JSONL.
Mirrors daily summaries, sleep data, and activities into:
- data/garmin/daily/<YYYY-MM>.jsonl
- data/garmin/sleep/<YYYY-MM>.jsonl
- data/garmin/activities/<YYYY-MM>.jsonl
Adheres to ADR 0003 (Git as single source of truth) and ADR 0004 (Structured Datastore).
Zero LLM math: records are stored as raw JSONL for deterministic DuckDB ingestion.
"""
from __future__ import annotations
import argparse
import datetime
import json
import logging
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
try:
import pytz
except ImportError:
pytz = None
logger = logging.getLogger("garmin_sync")
def get_berlin_yesterday() -> str:
"""Returns yesterday's date in Europe/Berlin wall-clock time."""
if pytz:
tz = pytz.timezone("Europe/Berlin")
now = datetime.datetime.now(tz)
else:
# Fallback +2h offset for Berlin CEST
tz_offset = datetime.timezone(datetime.timedelta(hours=2))
now = datetime.datetime.now(tz_offset)
yesterday = now - datetime.timedelta(days=1)
return yesterday.strftime("%Y-%m-%d")
def find_default_data_dir() -> Path:
"""Finds the knowledge-wiki/data directory."""
env_dir = os.environ.get("DATASTORE_DATA_DIR")
if env_dir:
p = Path(env_dir)
if p.is_dir():
return p
candidates = [
Path("/home/node/.openclaw/wiki/main/data"),
Path(__file__).resolve().parents[4] / "knowledge-wiki" / "data",
Path.cwd() / "knowledge-wiki" / "data",
Path.cwd() / "data",
]
for c in candidates:
if c.is_dir():
return c
# Fallback to local data dir inside parent
return Path.cwd() / "data"
def get_token_dir(custom_path: Optional[str] = None) -> Path:
"""Discovers token directory according to garmin-auth conventions."""
if custom_path:
return Path(custom_path)
if os.environ.get("GARMINTOKENS"):
return Path(os.path.expanduser(os.environ["GARMINTOKENS"]))
if Path("/home/node/.openclaw/garmin_tokens").is_dir():
return Path("/home/node/.openclaw/garmin_tokens")
return Path(os.path.expanduser("~/.garminconnect"))
class GarminSyncClient:
"""Garmin API client with token re-use and optional mock mode."""
def __init__(self, token_dir: Path, mock: bool = False):
self.token_dir = token_dir
self.mock = mock
self.client: Any = None
if not self.mock:
self._init_live_client()
def _init_live_client(self):
token_file = self.token_dir / "garmin_tokens.json"
if not token_file.is_file():
logger.error(f"No Garmin tokens found at {token_file}")
raise FileNotFoundError(f"Missing garmin_tokens.json in {self.token_dir}")
try:
from garminconnect import Garmin
self.client = Garmin()
self.client.login(str(self.token_dir))
name = self.client.get_full_name()
logger.info(f"Connected to Garmin as {name}")
except Exception as e:
logger.error(f"Failed to authenticate with Garmin Connect: {e}")
raise PermissionError(f"Garmin auth failed: {e}") from e
def get_daily_summary(self, date_str: str) -> Dict[str, Any]:
if self.mock:
return {
"calendarDate": date_str,
"totalKilocalories": 2850,
"activeKilocalories": 610,
"bmrKilocalories": 2240,
"totalSteps": 9840,
"totalDistanceMeters": 7450.0,
"restingHeartRate": 52,
"minHeartRate": 48,
"maxHeartRate": 164,
"floorsAscended": 12,
"source": "garmin_sync_mock",
}
return self.client.get_user_summary(date_str)
def get_sleep_data(self, date_str: str) -> Dict[str, Any]:
if self.mock:
return {
"dailySleepDTO": {
"id": 12345678,
"calendarDate": date_str,
"sleepTimeSeconds": 28800,
"napTimeSeconds": 0,
"deepSleepSeconds": 7200,
"lightSleepSeconds": 14400,
"remSleepSeconds": 5400,
"awakeSleepSeconds": 1800,
"sleepScores": {"overall": {"value": 85, "qualifierKey": "EXCELLENT"}},
},
"source": "garmin_sync_mock",
}
return self.client.get_sleep_data(date_str)
def get_activities(self, date_str: str) -> List[Dict[str, Any]]:
if self.mock:
return [
{
"activityId": 987654321,
"activityName": "Indoor Cycling (Tacx Flux 2)",
"startTimeLocal": f"{date_str} 18:30:00",
"startTimeGMT": f"{date_str} 16:30:00",
"activityType": {"typeId": 2, "typeKey": "cycling"},
"distance": 25400.0,
"duration": 3600.0,
"elapsedDuration": 3600.0,
"calories": 580.0,
"averageSpeed": 7.05,
"maxSpeed": 11.2,
"averageHR": 142,
"maxHR": 168,
"averagePower": 210,
"maxPower": 285,
"source": "garmin_sync_mock",
}
]
return self.client.get_activities_by_date(date_str, date_str)
class GarminMirrorWriter:
"""Manages month-partitioned JSONL files with idempotent upserts."""
def __init__(self, data_dir: Path):
self.garmin_dir = data_dir / "garmin"
self.daily_dir = self.garmin_dir / "daily"
self.sleep_dir = self.garmin_dir / "sleep"
self.activities_dir = self.garmin_dir / "activities"
for d in [self.daily_dir, self.sleep_dir, self.activities_dir]:
d.mkdir(parents=True, exist_ok=True)
def _read_existing(self, file_path: Path) -> List[Dict[str, Any]]:
if not file_path.is_file():
return []
items = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
items.append(json.loads(line))
except json.JSONDecodeError:
continue
return items
def _write_atomic(self, file_path: Path, items: List[Dict[str, Any]]):
tmp_path = file_path.with_suffix(".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
for item in items:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
tmp_path.replace(file_path)
def upsert_daily_summary(self, summary: Dict[str, Any]):
cal_date = summary.get("calendarDate")
if not cal_date:
logger.warning("Daily summary missing calendarDate, skipping.")
return
month = cal_date[:7] # YYYY-MM
target_file = self.daily_dir / f"{month}.jsonl"
existing = self._read_existing(target_file)
updated = False
new_items = []
for item in existing:
if item.get("calendarDate") == cal_date:
new_items.append(summary)
updated = True
else:
new_items.append(item)
if not updated:
new_items.append(summary)
new_items.sort(key=lambda x: str(x.get("calendarDate", "")))
self._write_atomic(target_file, new_items)
logger.info(f"Upserted daily summary for {cal_date} into {target_file.name}")
def upsert_sleep_data(self, sleep_data: Dict[str, Any]):
cal_date = None
if "dailySleepDTO" in sleep_data and isinstance(sleep_data["dailySleepDTO"], dict):
cal_date = sleep_data["dailySleepDTO"].get("calendarDate")
if not cal_date:
cal_date = sleep_data.get("calendarDate")
if not cal_date:
logger.warning("Sleep data missing calendarDate, skipping.")
return
month = cal_date[:7]
target_file = self.sleep_dir / f"{month}.jsonl"
existing = self._read_existing(target_file)
updated = False
new_items = []
for item in existing:
item_date = None
if "dailySleepDTO" in item and isinstance(item["dailySleepDTO"], dict):
item_date = item["dailySleepDTO"].get("calendarDate")
if not item_date:
item_date = item.get("calendarDate")
if item_date == cal_date:
new_items.append(sleep_data)
updated = True
else:
new_items.append(item)
if not updated:
new_items.append(sleep_data)
def _sort_key(x):
d = (x.get("dailySleepDTO") or {}).get("calendarDate") if isinstance(x.get("dailySleepDTO"), dict) else None
return str(d or x.get("calendarDate") or "")
new_items.sort(key=_sort_key)
self._write_atomic(target_file, new_items)
logger.info(f"Upserted sleep data for {cal_date} into {target_file.name}")
def upsert_activities(self, activities: List[Dict[str, Any]]):
if not activities:
return
# Group activities by month
by_month: Dict[str, List[Dict[str, Any]]] = {}
for act in activities:
start_local = act.get("startTimeLocal", "")
if len(start_local) >= 7:
month = start_local[:7]
else:
month = datetime.date.today().strftime("%Y-%m")
by_month.setdefault(month, []).append(act)
for month, act_list in by_month.items():
target_file = self.activities_dir / f"{month}.jsonl"
existing = self._read_existing(target_file)
id_map = {item.get("activityId"): item for item in existing if item.get("activityId")}
for act in act_list:
act_id = act.get("activityId")
if act_id:
id_map[act_id] = act
all_acts = list(id_map.values())
all_acts.sort(key=lambda x: str(x.get("startTimeLocal", "")))
self._write_atomic(target_file, all_acts)
logger.info(f"Upserted {len(act_list)} activities for month {month} into {target_file.name}")
def sync_date_range(client: GarminSyncClient, writer: GarminMirrorWriter, dates: List[str]):
for d in dates:
logger.info(f"--- Syncing Garmin data for {d} ---")
try:
summary = client.get_daily_summary(d)
if summary:
writer.upsert_daily_summary(summary)
except Exception as e:
logger.warning(f"Could not fetch daily summary for {d}: {e}")
try:
sleep = client.get_sleep_data(d)
if sleep:
writer.upsert_sleep_data(sleep)
except Exception as e:
logger.warning(f"Could not fetch sleep data for {d}: {e}")
try:
activities = client.get_activities(d)
if activities:
writer.upsert_activities(activities)
except Exception as e:
logger.warning(f"Could not fetch activities for {d}: {e}")
def generate_date_list(start_str: str, end_str: str) -> List[str]:
d_start = datetime.datetime.strptime(start_str, "%Y-%m-%d").date()
d_end = datetime.datetime.strptime(end_str, "%Y-%m-%d").date()
if d_start > d_end:
d_start, d_end = d_end, d_start
result = []
curr = d_start
while curr <= d_end:
result.append(curr.strftime("%Y-%m-%d"))
curr += datetime.timedelta(days=1)
return result
def main():
parser = argparse.ArgumentParser(description="Deterministic Garmin Connect data mirror into JSONL")
parser.add_argument("--date", help="Specific date to sync (YYYY-MM-DD). Defaults to yesterday.")
parser.add_argument("--days", type=int, help="Sync last N days up to yesterday.")
parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", help="End date (YYYY-MM-DD)")
parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory")
parser.add_argument("--token-dir", help="Path to Garmin token directory")
parser.add_argument("--mock", action="store_true", help="Run with mock data (for testing/offline)")
parser.add_argument("--verbose", action="store_true", help="Enable verbose debug logging")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
data_dir = Path(args.data_dir) if args.data_dir else find_default_data_dir()
token_dir = get_token_dir(args.token_dir)
# Determine date list
if args.start and args.end:
dates = generate_date_list(args.start, args.end)
elif args.days:
yesterday_str = get_berlin_yesterday()
d_yesterday = datetime.datetime.strptime(yesterday_str, "%Y-%m-%d").date()
d_start = d_yesterday - datetime.timedelta(days=args.days - 1)
dates = generate_date_list(d_start.strftime("%Y-%m-%d"), yesterday_str)
elif args.date:
dates = [args.date]
else:
dates = [get_berlin_yesterday()]
logger.info(f"Starting Garmin sync for {len(dates)} day(s): {dates[0]} to {dates[-1]}")
logger.info(f"Data directory: {data_dir}")
try:
client = GarminSyncClient(token_dir, mock=args.mock)
except PermissionError:
logger.error("Authentication error. Please run 'garmin-auth' to refresh credentials.")
sys.exit(2)
except FileNotFoundError as e:
logger.error(str(e))
sys.exit(2)
writer = GarminMirrorWriter(data_dir)
sync_date_range(client, writer, dates)
logger.info("Garmin sync completed successfully.")
sys.exit(0)
if __name__ == "__main__":
main()
+534
View File
@@ -0,0 +1,534 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "duckdb>=1.1.0",
# "python-ulid>=3.0.0",
# "pytz>=2024.1",
# ]
# ///
"""
migrate_legacy.py — Migrates legacy Markdown logs to DuckDB-backed JSONL datastore.
Parses:
- standard-foods.md -> data/entities/foods.jsonl
- entities/nutrition-log/moritz/*.md -> data/events/nutrition/*.jsonl
- entities/people/mila-schlafprotokoll.md -> data/events/sleep/*.jsonl & feeding/*.jsonl
Computes exact DuckDB sums, compares with old handwritten sums, and generates audit report.
"""
from __future__ import annotations
import datetime
import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
def generate_ulid() -> str:
try:
from ulid import ULID
return str(ULID())
except TypeError:
import ulid
return str(ulid.new())
except ImportError:
import uuid
return str(uuid.uuid4())
import duckdb
BERLIN_TZ = ZoneInfo("Europe/Berlin")
WIKI_DIR = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki")
DATA_DIR = WIKI_DIR / "data"
def parse_number(s: str) -> float:
"""Parses German float string like '~11,3' or '2.364' into float."""
if not s or s.strip() in ["-", "–", ""]:
return 0.0
cleaned = s.replace("~", "").replace("g", "").replace("ml", "").replace("kcal", "").strip()
# If number has both dot and comma (e.g. 1.234,56 or 2.364)
if "." in cleaned and "," in cleaned:
cleaned = cleaned.replace(".", "").replace(",", ".")
elif "," in cleaned:
cleaned = cleaned.replace(",", ".")
elif "." in cleaned:
# Check if dot is thousands separator (e.g. 2.364 kcal)
parts = cleaned.split(".")
if len(parts) == 2 and len(parts[1]) == 3:
cleaned = parts[0] + parts[1]
m = re.search(r"[-+]?\d*\.?\d+", cleaned)
return float(m.group(0)) if m else 0.0
# ==============================================================================
# 1. Migrate Standard Foods
# ==============================================================================
def migrate_standard_foods() -> List[Dict[str, Any]]:
foods_file = DATA_DIR / "entities" / "foods.jsonl"
foods_file.parent.mkdir(parents=True, exist_ok=True)
foods = [
{
"id": "oatly-haferdrink",
"name": "Oatly Haferdrink",
"brand": "Oatly AB",
"category": "milch-alternative",
"per_100": {
"kcal": 61.0,
"protein_g": 1.1,
"carbs_g": 7.1,
"sugar_g": 3.4,
"fat_g": 3.0,
"fiber_g": 0.8,
"salt_g": 0.10,
},
"density_g_per_ml": 1.03,
"ingredients_text": "Wasser, HAFER 10%, Rapsöl, Säureregulator (Dikaliumphosphat), Mineralien (Calciumcarbonat, Kaliumjodid), Salz, Vitamine (D2, Riboflavin, B12).",
"barcode": "7394376616037",
"source": "package_photo",
"description": "Standard-Hafermilch Grabengasse 7. Silberner Karton mit Vitaminen & Mineralien.",
},
{
"id": "fruechte-mix-tk",
"name": "Früchte Mix (TK)",
"brand": None,
"category": "obst",
"per_100": {
"kcal": 45.0,
"protein_g": 1.0,
"carbs_g": 6.5,
"sugar_g": 3.4,
"fat_g": 0.3,
"fiber_g": 3.4,
"salt_g": 0.0,
},
"density_g_per_ml": 1.0,
"ingredients_text": "Gefrorene Beeren-Mischung (TK)",
"barcode": None,
"source": "package_photo",
"description": "Standard gefrorener Beerenmix für Porridge und Overnight Oats.",
},
{
"id": "muller-protein-porridge-mix",
"name": "Müller Protein-Porridge Batch-Mix",
"brand": "Eigenmischung",
"category": "muesli",
"per_100": {
"kcal": 381.0,
"protein_g": 23.3,
"carbs_g": 46.6,
"sugar_g": 2.9,
"fat_g": 8.5,
"fiber_g": 9.8,
"salt_g": 0.15,
},
"density_g_per_ml": 1.0,
"ingredients_text": "Müller Porridge Basic 500g + Müller Eiweißpulver 100g + Chiasamen 50g",
"barcode": None,
"source": "recipe_batch",
"description": "Eigener Batch-Mix für Morgenporridge (Müller Porridge Basic + Eiweißpulver + Chiasamen).",
},
]
with open(foods_file, "w", encoding="utf-8") as f:
for item in foods:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
return foods
# ==============================================================================
# 2. Migrate Nutrition Logs
# ==============================================================================
def migrate_nutrition_logs() -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
nutrition_dir = WIKI_DIR / "entities" / "nutrition-log" / "moritz"
events_dir = DATA_DIR / "events" / "nutrition"
events_dir.mkdir(parents=True, exist_ok=True)
all_events: List[Dict[str, Any]] = []
stated_sums: Dict[str, Dict[str, Any]] = {}
for md_file in sorted(nutrition_dir.glob("2026-*.md")):
month_str = md_file.stem
file_events: List[Dict[str, Any]] = []
current_date_str = None
current_day_name = None
with open(md_file, "r", encoding="utf-8") as f:
for line in f:
line_str = line.strip()
# Match date header: ## YYYY-MM-DD Wochentag
m_date = re.match(r"^##\s+(\d{4}-\d{2}-\d{2})(?:\s+(\w+))?", line_str)
if m_date:
current_date_str = m_date.group(1)
current_day_name = m_date.group(2) or ""
continue
# Match table row
if current_date_str and line_str.startswith("|") and not line_str.startswith("| Zeit") and not line_str.startswith("|---"):
cols = [c.strip() for c in line_str.strip("|").split("|")]
if len(cols) >= 10:
zeit_raw = cols[0]
item_raw = cols[1]
menge_raw = cols[2]
eiweiss_raw = cols[3]
kh_raw = cols[4]
zucker_raw = cols[5]
fett_raw = cols[6]
ballast_raw = cols[7]
salz_raw = cols[8]
kcal_raw = cols[9]
# Parse time (default 12:00 if unknown)
m_time = re.search(r"(\d{1,2}):(\d{2})", zeit_raw)
if m_time:
hour = int(m_time.group(1))
minute = int(m_time.group(2))
else:
hour, minute = 12, 0
event_dt = datetime.datetime.strptime(current_date_str, "%Y-%m-%d").replace(
hour=hour, minute=minute, tzinfo=BERLIN_TZ
)
# Determine food_id if matched
food_id = None
if "oatly" in item_raw.lower() or "haferdrink" in item_raw.lower():
food_id = "oatly-haferdrink"
elif "früchte mix" in item_raw.lower() or "beeren" in item_raw.lower():
food_id = "fruechte-mix-tk"
elif "porridge-mischung" in item_raw.lower() or "müller protein-porridge" in item_raw.lower():
food_id = "muller-protein-porridge-mix"
# Parse quantity and unit
m_qty = re.search(r"(\d+(?:[.,]\d+)?)\s*(g|ml|portion|kugel)?", menge_raw)
qty = float(m_qty.group(1).replace(",", ".")) if m_qty else parse_number(menge_raw)
unit = m_qty.group(2) if m_qty and m_qty.group(2) else "g"
is_estimated = "~" in zeit_raw or "~" in item_raw or "~" in menge_raw or "~" in kcal_raw
record = {
"id": generate_ulid(),
"kind": "nutrition.intake",
"subject": "moritz",
"actor": "agent:koch",
"start": event_dt.isoformat(),
"end": None,
"estimated": is_estimated,
"meal": None,
"note": None,
"payload": {
"item": item_raw,
"food_id": food_id,
"qty": qty,
"unit": unit,
"protein_g": parse_number(eiweiss_raw),
"carbs_g": parse_number(kh_raw),
"sugar_g": parse_number(zucker_raw),
"fat_g": parse_number(fett_raw),
"fiber_g": parse_number(ballast_raw),
"salt_g": parse_number(salz_raw),
"kcal": parse_number(kcal_raw),
},
"source_ref": f"{md_file.name}:{zeit_raw}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
file_events.append(record)
all_events.append(record)
# Match stated sums: **Summe heute:** ...
if current_date_str and "**Summe heute:**" in line_str:
sum_text = line_str.split("**Summe heute:**")[1].strip()
stated_sums[current_date_str] = {
"text": sum_text,
"protein_g": parse_number(re.search(r"([\d,]+)\s*g\s*Eiweiß", sum_text).group(1)) if re.search(r"([\d,]+)\s*g\s*Eiweiß", sum_text) else 0.0,
"carbs_g": parse_number(re.search(r"([\d,]+)\s*g\s*KH", sum_text).group(1)) if re.search(r"([\d,]+)\s*g\s*KH", sum_text) else 0.0,
"kcal": parse_number(re.search(r"([\d,.]+)\s*kcal", sum_text).group(1)) if re.search(r"([\d,.]+)\s*kcal", sum_text) else 0.0,
}
# Write partitioned JSONL
file_events.sort(key=lambda x: (x["start"], x["id"]))
out_jsonl = events_dir / f"{month_str}.jsonl"
with open(out_jsonl, "w", encoding="utf-8") as out_f:
for ev in file_events:
out_f.write(json.dumps(ev, ensure_ascii=False) + "\n")
return all_events, stated_sums
# ==============================================================================
# 3. Migrate Mila Sleep & Feeding Logs
# ==============================================================================
def migrate_mila_sleep_and_feeding() -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
md_file = WIKI_DIR / "entities" / "people" / "mila-schlafprotokoll.md"
sleep_events: List[Dict[str, Any]] = []
feeding_events: List[Dict[str, Any]] = []
if not md_file.is_file():
return sleep_events, feeding_events
current_date = None
processed_nights = set()
with open(md_file, "r", encoding="utf-8") as f:
for line in f:
line_str = line.strip()
# Date header: ## Dienstag, 29.09.2026 or ## 2026-09-29
m_date = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", line_str)
if line_str.startswith("##") and m_date:
day, month, year = int(m_date.group(1)), int(m_date.group(2)), int(m_date.group(3))
current_date = datetime.date(year, month, day)
continue
if not current_date or not line_str.startswith("-"):
continue
# Case A: Feeding log (e.g. - 08:15 – Fläschchen (80 ml übrig/ungetrunken))
if "fläschchen" in line_str.lower():
m_time = re.search(r"(\d{1,2}):(\d{2})", line_str)
h, m = (int(m_time.group(1)), int(m_time.group(2))) if m_time else (8, 0)
feed_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, h, m, tzinfo=BERLIN_TZ)
m_ml = re.search(r"(\d+)\s*ml", line_str)
left_ml = float(m_ml.group(1)) if m_ml else 0.0
record = {
"id": generate_ulid(),
"kind": "feeding.bottle",
"subject": "mila",
"actor": "agent:hebamme",
"start": feed_dt.isoformat(),
"end": None,
"estimated": False,
"meal": None,
"note": line_str.lstrip("- ").strip(),
"payload": {
"offered_ml": None,
"consumed_ml": None,
"left_ml": left_ml,
"milk_type": "pre",
},
"source_ref": f"mila-schlafprotokoll.md:{feed_dt.strftime('%Y-%m-%d %H:%M')}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
feeding_events.append(record)
continue
# Case B: Sleep intervals
# Examples:
# - 09:51 – 10:38 | Schlaf am Morgen (46 min: 10 min wach, 6 Leicht-, 30 Tiefschlaf; Einschlafzeit 5 min)
# - 18:57 – 06:50 | Nacht (11 h 53 min)
# - 20:10 – | Nacht (läuft)
# - 07:00 – aufgewacht (Nacht-Ende); Nacht: 19:13 – 07:00 (11 h 47 min)
# Check for "aufgewacht (Nacht-Ende)" duplicate
if "aufgewacht (nacht-ende)" in line_str.lower() or "nacht-ende" in line_str.lower():
m_span = re.search(r"(\d{1,2}):(\d{2})\s*–\s*(\d{1,2}):(\d{2})", line_str)
if m_span:
s_h, s_m, e_h, e_m = int(m_span.group(1)), int(m_span.group(2)), int(m_span.group(3)), int(m_span.group(4))
night_key = f"{s_h:02d}:{s_m:02d}-{e_h:02d}:{e_m:02d}"
if night_key in processed_nights:
# Already logged from the evening line, skip duplicate!
continue
else:
processed_nights.add(night_key)
# The start was yesterday evening
start_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, s_h, s_m, tzinfo=BERLIN_TZ) - datetime.timedelta(days=1)
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ)
record = {
"id": generate_ulid(),
"kind": "sleep.session",
"subject": "mila",
"actor": "agent:hebamme",
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"estimated": False,
"meal": None,
"note": line_str.lstrip("- ").strip(),
"payload": {
"type": "night",
"location": "bett",
},
"source_ref": f"mila-schlafprotokoll.md:{current_date}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
sleep_events.append(record)
continue
# Standard interval: start – end | label
m_int = re.search(r"^-\s*(\d{1,2}):(\d{2})\s*–\s*(\d{1,2})?:?(\d{2})?\s*\|?(.*)", line_str)
if m_int:
s_h = int(m_int.group(1))
s_m = int(m_int.group(2))
e_h_str = m_int.group(3)
e_m_str = m_int.group(4)
desc = m_int.group(5).strip() if m_int.group(5) else ""
start_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, s_h, s_m, tzinfo=BERLIN_TZ)
# Open sleep interval (e.g. 20:10 – | Nacht (läuft))
if not e_h_str or not e_m_str:
end_dt = None
sleep_type = "night" if s_h >= 18 or s_h < 6 else "nap"
else:
e_h = int(e_h_str)
e_m = int(e_m_str)
# If end hour < start hour, crosses midnight!
if e_h < s_h:
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ) + datetime.timedelta(days=1)
else:
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ)
sleep_type = "night" if "nacht" in desc.lower() or s_h >= 18 else "nap"
# Deduplication key for night sleeps
night_key = f"{s_h:02d}:{s_m:02d}-{e_h:02d}:{e_m:02d}"
if sleep_type == "night":
processed_nights.add(night_key)
# Location detection
loc = "bett"
if "auto" in desc.lower():
loc = "auto"
elif "kinderwagen" in desc.lower():
loc = "kinderwagen"
elif "outdoor" in desc.lower() or "decke" in desc.lower():
loc = "outdoor"
record = {
"id": generate_ulid(),
"kind": "sleep.session",
"subject": "mila",
"actor": "agent:hebamme",
"start": start_dt.isoformat(),
"end": end_dt.isoformat() if end_dt else None,
"estimated": False,
"meal": None,
"note": desc or None,
"payload": {
"type": sleep_type,
"location": loc,
},
"source_ref": f"mila-schlafprotokoll.md:{current_date}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
sleep_events.append(record)
# Write partition files
sleep_dir = DATA_DIR / "events" / "sleep"
sleep_dir.mkdir(parents=True, exist_ok=True)
feeding_dir = DATA_DIR / "events" / "feeding"
feeding_dir.mkdir(parents=True, exist_ok=True)
# Group sleep by month
sleep_by_month: Dict[str, List[Dict[str, Any]]] = {}
for ev in sleep_events:
m_str = ev["start"][:7] # YYYY-MM
sleep_by_month.setdefault(m_str, []).append(ev)
for m_str, m_events in sleep_by_month.items():
m_events.sort(key=lambda x: (x["start"], x["id"]))
with open(sleep_dir / f"{m_str}.jsonl", "w", encoding="utf-8") as f:
for ev in m_events:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
# Group feeding by month
feed_by_month: Dict[str, List[Dict[str, Any]]] = {}
for ev in feeding_events:
m_str = ev["start"][:7]
feed_by_month.setdefault(m_str, []).append(ev)
for m_str, m_events in feed_by_month.items():
m_events.sort(key=lambda x: (x["start"], x["id"]))
with open(feeding_dir / f"{m_str}.jsonl", "w", encoding="utf-8") as f:
for ev in m_events:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
return sleep_events, feeding_events
# ==============================================================================
# 4. Generate Audit Report
# ==============================================================================
def generate_audit_report(stated_sums: Dict[str, Dict[str, Any]]) -> str:
con = duckdb.connect(":memory:")
# Initialize schema
schema_dir = DATA_DIR / "_schema"
for sql_f in ["envelope.sql", "entities.sql", "views.sql"]:
con.execute((schema_dir / sql_f).read_text(encoding="utf-8"))
# Load foods
foods_file = DATA_DIR / "entities" / "foods.jsonl"
if foods_file.is_file():
con.execute(f"INSERT INTO foods SELECT * FROM read_json_auto('{foods_file}')")
# Load all nutrition events
nut_files = str(DATA_DIR / "events" / "nutrition" / "*.jsonl")
con.execute(f"INSERT INTO events SELECT * FROM read_json_auto('{nut_files}')")
# Query daily sums
db_sums = con.execute("SELECT log_date, total_protein_g, total_carbs_g, total_kcal, item_count FROM v_nutrition_daily ORDER BY log_date ASC").fetchall()
lines = []
lines.append("# 📊 Datastore Migration: Arithmetic Discrepancy Audit Report\n")
lines.append(f"**Generated:** {datetime.datetime.now(BERLIN_TZ).strftime('%Y-%m-%d %H:%M:%S')} ")
lines.append("**Source of Truth:** DuckDB engine over `data/events/nutrition/*.jsonl` \n")
lines.append("This audit compares the old handwritten Markdown `**Summe heute:**` against the mathematically exact DuckDB column summations.\n")
lines.append("| Date | Items | Hand Stated Protein | DuckDB Exact Protein | Δ Protein | Hand Stated kcal | DuckDB Exact kcal | Δ kcal |")
lines.append("| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |")
for row in db_sums:
d_str = str(row[0])
p_exact = row[1]
k_exact = int(row[3])
count = row[4]
stated = stated_sums.get(d_str, {})
p_hand = stated.get("protein_g", 0.0)
k_hand = int(stated.get("kcal", 0))
delta_p = round(p_exact - p_hand, 1)
delta_k = k_exact - k_hand
p_diff_str = f"**{delta_p:+.1f} g**" if abs(delta_p) > 0.1 else "✅ 0.0"
k_diff_str = f"**{delta_k:+d} kcal**" if abs(delta_k) > 2 else "✅ 0"
lines.append(f"| {d_str} | {count} | {p_hand:.1f} g | {p_exact:.1f} g | {p_diff_str} | {k_hand} | {k_exact} | {k_diff_str} |")
return "\n".join(lines)
if __name__ == "__main__":
print("1. Migrating Standard Foods...")
foods = migrate_standard_foods()
print(f" -> Migrated {len(foods)} food reference items.")
print("2. Migrating Nutrition Logs...")
nut_events, stated_sums = migrate_nutrition_logs()
print(f" -> Migrated {len(nut_events)} nutrition events across {len(stated_sums)} days.")
print("3. Migrating Mila Sleep & Feeding Logs...")
sleep_events, feed_events = migrate_mila_sleep_and_feeding()
print(f" -> Migrated {len(sleep_events)} sleep events and {len(feed_events)} bottle feeding events.")
print("4. Generating Discrepancy Audit Report...")
report = generate_audit_report(stated_sums)
report_file = WIKI_DIR / "reports" / "datastore-migration-audit.md"
report_file.parent.mkdir(parents=True, exist_ok=True)
report_file.write_text(report, encoding="utf-8")
print(f" -> Wrote audit report to {report_file}")
print("\n--- Summary ---")
print(report)
+584
View File
@@ -0,0 +1,584 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "duckdb>=1.1.0",
# "python-ulid>=3.0.0",
# "pytz>=2024.1",
# ]
# ///
"""
render_views.py — Generates clean Markdown view pages and HTML dashboards from DuckDB JSONL.
Uses DatastoreEngine for exact schema validation, typed loading, and Berlin timezone attribution.
"""
from __future__ import annotations
import argparse
import datetime
import os
from pathlib import Path
from zoneinfo import ZoneInfo
from datastore import DatastoreEngine
BERLIN_TZ = ZoneInfo("Europe/Berlin")
WEEKDAYS_DE = {
0: "Montag",
1: "Dienstag",
2: "Mittwoch",
3: "Donnerstag",
4: "Freitag",
5: "Samstag",
6: "Sonntag",
}
def find_default_dirs():
candidates = [
Path.cwd() / "knowledge-wiki",
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
Path("/home/node/.openclaw/wiki/main"),
]
for c in candidates:
if (c / "data").is_dir():
return c, c / "data"
return Path.cwd(), Path.cwd() / "data"
def render_nutrition_views(engine: DatastoreEngine, wiki_dir: Path):
con = engine.con
months = ["2026-09", "2026-10"]
for m in months:
out_file = wiki_dir / "entities" / "nutrition-log" / "moritz" / f"{m}.md"
out_file.parent.mkdir(parents=True, exist_ok=True)
month_name = "September" if m.endswith("09") else "Oktober"
# Query days in month descending
days = con.execute(
f"""
SELECT DISTINCT log_date
FROM v_nutrition
WHERE strftime(log_date, '%Y-%m') = '{m}'
ORDER BY log_date DESC
"""
).fetchall()
md = []
md.append("---")
md.append(f"id: nutrition-log-moritz-{m}")
md.append("pageType: source")
md.append(f'title: "Ernährungsprotokoll Moritz — {month_name} 2026"')
md.append(f"month: {m}")
md.append("tags:")
md.append(" - nutrition-log")
md.append(" - personal")
md.append("lifecycle:")
md.append(" state: active")
md.append("---")
md.append("")
md.append(f"# Ernährungsprotokoll Moritz — {month_name} 2026")
md.append("")
md.append("<!-- DO NOT EDIT: GENERATED BY DATASTORE -->")
md.append("<!-- Source of Truth: data/events/nutrition/{YYYY-MM}.jsonl -->")
md.append("")
for (d,) in days:
d_str = str(d)
d_date = datetime.date.fromisoformat(d_str)
weekday_str = WEEKDAYS_DE[d_date.weekday()]
# Fetch daily aggregate
daily_agg = con.execute(
f"""
SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_fiber_g, total_salt_g, total_kcal
FROM v_nutrition_daily
WHERE log_date = '{d_str}' AND subject = 'moritz'
"""
).fetchone()
# Check if daily balance against Garmin is available
balance_row = con.execute(
f"""
SELECT intake_kcal, expenditure_kcal, active_kcal, rmr_kcal, steps, balance_kcal, balance_status
FROM v_daily_balance
WHERE log_date = '{d_str}' AND subject = 'moritz'
"""
).fetchone()
md.append(f"## {d_str} {weekday_str}")
md.append("")
md.append("| Zeit | Input (original) | Menge | Eiweiß | KH | Zucker | Fett | Ballaststoffe | Salz | kcal |")
md.append("|---|---|---|---|---|---|---|---|---|---|")
# Fetch rows for this day
rows = con.execute(
f"""
SELECT strftime(local_time, '%H:%M'), item, qty, unit, protein_g, carbs_g, sugar_g, fat_g, fiber_g, salt_g, kcal, estimated
FROM v_nutrition
WHERE log_date = '{d_str}' AND subject = 'moritz'
ORDER BY start ASC
"""
).fetchall()
for r in rows:
t_str = r[0]
item = r[1]
qty = f"{int(r[2]) if r[2].is_integer() else r[2]} {r[3]}" if r[2] is not None and r[3] else ""
est = "~" if r[11] else ""
p = f"{est}{r[4]:.1f} g"
c = f"{est}{r[5]:.1f} g"
s = f"{est}{r[6]:.1f} g"
f_val = f"{est}{r[7]:.1f} g"
fib = f"{est}{r[8]:.1f} g"
salt = f"{est}{r[9]:.2f} g"
kc = f"{est}{int(round(r[10]))}"
md.append(f"| {t_str} | {item} | {qty} | {p} | {c} | {s} | {f_val} | {fib} | {salt} | {kc} |")
if daily_agg:
tot_p = f"{daily_agg[0]:.1f} g"
tot_c = f"{daily_agg[1]:.1f} g"
tot_s = f"{daily_agg[2]:.1f} g"
tot_f = f"{daily_agg[3]:.1f} g"
tot_fib = f"{daily_agg[4]:.1f} g"
tot_salt = f"{daily_agg[5]:.2f} g"
tot_kc = f"**{int(round(daily_agg[6]))}**"
md.append(f"| **Tages-Summe** | | | **{tot_p}** | **{tot_c}** | **{tot_s}** | **{tot_f}** | **{tot_fib}** | **{tot_salt}** | {tot_kc} |")
md.append("")
# Render Garmin balance block if available
if balance_row and balance_row[1] is not None:
intake = int(round(balance_row[0]))
expend = int(round(balance_row[1]))
diff = int(round(balance_row[5]))
steps = balance_row[4] or 0
active = balance_row[2] or 0
sign = "+" if diff > 0 else ""
md.append(f"- **Vortags-Bilanz (Garmin)**: {intake} kcal Verzehr - {expend} kcal Verbrauch = **{sign}{diff} kcal {balance_row[6]}** ({steps} Schritte, {active} Aktiv-kcal)")
md.append("")
out_file.write_text("\n".join(md), encoding="utf-8")
print(f"Rendered {out_file}")
def render_sleep_view(engine: DatastoreEngine, wiki_dir: Path):
con = engine.con
out_file = wiki_dir / "entities" / "people" / "mila-schlafprotokoll.md"
out_file.parent.mkdir(parents=True, exist_ok=True)
days = con.execute(
"""
SELECT DISTINCT log_date
FROM v_sleep
WHERE subject = 'mila'
ORDER BY log_date DESC
"""
).fetchall()
md = []
md.append("---")
md.append("id: mila-schlafprotokoll")
md.append("pageType: log")
md.append('title: "Schlafprotokoll Mila"')
md.append("subject: mila")
md.append("tags:")
md.append(" - sleep")
md.append(" - baby")
md.append(" - mila")
md.append("lifecycle:")
md.append(" state: active")
md.append("---")
md.append("")
md.append("# Schlafprotokoll Mila")
md.append("")
md.append("<!-- DO NOT EDIT: GENERATED BY DATASTORE -->")
md.append("<!-- Source of Truth: data/events/sleep/{YYYY-MM}.jsonl -->")
md.append("")
current_month_label = ""
for (d,) in days:
d_str = str(d)
d_date = datetime.date.fromisoformat(d_str)
month_label = f"{'September' if d_date.month == 9 else 'Oktober'} {d_date.year}"
if month_label != current_month_label:
current_month_label = month_label
md.append(f"# {month_label}")
md.append("")
weekday_str = WEEKDAYS_DE[d_date.weekday()]
daily_agg = con.execute(
f"""
SELECT total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes
FROM v_sleep_daily
WHERE log_date = '{d_str}' AND subject = 'mila'
"""
).fetchone()
day_sum_str = ""
if daily_agg:
tot_h = daily_agg[0]
naps = daily_agg[1]
day_sum_str = f" ({tot_h:.2f} h gesamt, {naps} Schläfchen)"
md.append(f"## {weekday_str}, {d_date.strftime('%d.%m.%Y')}{day_sum_str}")
sessions = con.execute(
f"""
SELECT strftime(local_start, '%H:%M'),
CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END,
sleep_type, location, note, duration_minutes, is_open
FROM v_sleep
WHERE log_date = '{d_str}' AND subject = 'mila'
ORDER BY start ASC
"""
).fetchall()
for s in sessions:
s_time = s[0]
e_time = s[1]
s_type = s[2]
loc = s[3]
note = s[4] or ""
dur = s[5]
is_open = s[6]
dur_str = f"{dur // 60} h {dur % 60:02d} min" if dur >= 60 else f"{dur} min"
if is_open:
md.append(f"- {s_time} – | {note or 'Nacht'} (läuft)")
else:
label = note if note else ("Nacht" if s_type == "night" else "Schläfchen")
md.append(f"- {s_time} – {e_time} | {label} ({dur_str})")
md.append("")
# Preserve narrative analysis block
md.append("""---
# Monatsanalyse September 2026 (nachgetragen 03.10.)
*Bewertet aus Sicht einer Kinderarzt-Praxis mit Schlafspezialisierung – Hinweise, keine Diagnose.*
**Zahlen (19.–30.09., 11 protokollierte Tage):**
- Nacht: Ø ~11 h 18 min (10 h 10 – 11 h 55)
- Tag: Ø ~2 h 29 min in 4–5 Schläfchen
- 24-h-Summe: Ø ~13 h 47 min
**Einordnung:** Das liegt am oberen, aber völlig normalen Rand des altersüblichen Bereichs (Säugling ~6 Mon.: 12–16 h/24 h, vgl. [Wikipedia: Schlaf](https://de.wikipedia.org/wiki/Schlaf) und [Wikipedia: Säugling](https://de.wikipedia.org/wiki/S%C3%A4ugling)).
**Trends im Monat:**
1. **Konsolidierung:** Von 5 kurzen Mikro-Schläfchen (Sep Anfang) hin zu 3–4 Schläfchen, erste lange Blöcke (85–140 min) am 20./21./27.09. und 02.10.
2. **Spätere Bettzeit:** 18:34–19:55 (Mitte Sep) → 20:10–21:00 (Ende Sep/Okt) – stabile Morgenwache ~06:00–06:50 bleibt.
3. **Durchschlafen:** Durchgehend lange Nachtblöcke, Wachphasen nachts selten und kurz.
**Erwartbar ab ~7–8 Monaten:** Übergang zum 3→2-Naps-Schema, Morgenschläfchen fällt zuerst weg. Zirkadiane Reifung unterstützt das (vgl. [Wikipedia: Zirkadiane Rhythmik](https://de.wikipedia.org/wiki/Zirkadiane_Rhythmik)).
---
""")
out_file.write_text("\n".join(md), encoding="utf-8")
print(f"Rendered {out_file}")
def render_html_dashboards(engine: DatastoreEngine, html_dir: Path):
html_dir.mkdir(parents=True, exist_ok=True)
con = engine.con
# 1. Nutrition HTML Dashboard
nutrition_days = con.execute(
"""
SELECT
d.log_date,
d.total_kcal,
d.total_protein_g,
d.total_carbs_g,
d.total_fat_g,
d.item_count,
b.expenditure_kcal,
b.balance_kcal,
b.balance_status,
b.steps
FROM v_nutrition_daily d
LEFT JOIN v_daily_balance b ON d.log_date = b.log_date AND d.subject = b.subject
WHERE d.subject = 'moritz'
ORDER BY d.log_date DESC
LIMIT 30
"""
).fetchall()
latest_n = nutrition_days[0] if nutrition_days else None
latest_kcal = int(round(latest_n[1])) if latest_n else 0
latest_protein = f"{latest_n[2]:.1f}" if latest_n else "0"
latest_garmin = f"{int(round(latest_n[6]))} kcal" if latest_n and latest_n[6] is not None else "Ausstehend"
latest_balance = f"{int(round(latest_n[7]))} kcal ({latest_n[8]})" if latest_n and latest_n[7] is not None else "—"
# Compute 7-day average
avg_7 = con.execute(
"""
SELECT ROUND(AVG(total_kcal), 0), ROUND(AVG(total_protein_g), 1)
FROM (SELECT total_kcal, total_protein_g FROM v_nutrition_daily WHERE subject = 'moritz' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
avg_7_kcal = int(avg_7[0]) if avg_7 and avg_7[0] is not None else 0
avg_7_prot = avg_7[1] if avg_7 and avg_7[1] is not None else 0
nutrition_rows_html = []
for r in nutrition_days:
d_str = str(r[0])
kc = int(round(r[1]))
prot = f"{r[2]:.1f}"
carbs = f"{r[3]:.1f}"
fat = f"{r[4]:.1f}"
exp = int(round(r[6])) if r[6] is not None else "—"
diff = int(round(r[7])) if r[7] is not None else "—"
diff_class = "text-emerald-400" if (r[7] is not None and r[7] < 0) else ("text-amber-400" if (r[7] is not None and r[7] > 0) else "text-slate-400")
steps = f"{r[9]:,}" if r[9] is not None else "—"
nutrition_rows_html.append(f"""
<tr class="border-b border-slate-700/50 hover:bg-slate-800/40 transition">
<td class="py-3 px-4 font-mono font-medium text-slate-200">{d_str}</td>
<td class="py-3 px-4 font-bold text-sky-400">{kc}</td>
<td class="py-3 px-4 font-semibold text-emerald-400">{prot} g</td>
<td class="py-3 px-4 text-slate-300">{carbs} g</td>
<td class="py-3 px-4 text-slate-300">{fat} g</td>
<td class="py-3 px-4 text-slate-400">{exp}</td>
<td class="py-3 px-4 font-bold {diff_class}">{diff}</td>
<td class="py-3 px-4 text-slate-400">{steps}</td>
</tr>
""")
nutrition_html = f"""<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moritz — Ernährung & Energiebilanz</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen p-4 sm:p-8 font-sans antialiased selection:bg-emerald-500 selection:text-black">
<div class="max-w-6xl mx-auto space-y-6">
<!-- Header -->
<header class="flex flex-col sm:flex-row sm:items-center justify-between pb-6 border-b border-slate-800 gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight bg-gradient-to-r from-sky-400 via-emerald-400 to-teal-300 bg-clip-text text-transparent">
Moritz — Ernährung & Energiebilanz
</h1>
<p class="text-sm text-slate-400 mt-1">Strikte DuckDB-Aggregatdaten · Single Source of Truth</p>
</div>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-900 border border-slate-800 text-xs text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span>Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')}</span>
</div>
</header>
<!-- Key Metrics Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Letzter Tag (Kcal)</div>
<div class="text-3xl font-extrabold text-sky-400 mt-2">{latest_kcal}</div>
<div class="text-xs text-slate-400 mt-1">7-Tage-Schnitt: {avg_7_kcal} kcal</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Eiweiß Intake</div>
<div class="text-3xl font-extrabold text-emerald-400 mt-2">{latest_protein} g</div>
<div class="text-xs text-slate-400 mt-1">7-Tage-Schnitt: {avg_7_prot} g</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Garmin Verbrauch</div>
<div class="text-3xl font-extrabold text-amber-400 mt-2">{latest_garmin}</div>
<div class="text-xs text-slate-400 mt-1">RMR + Aktivität</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Tages-Bilanz</div>
<div class="text-3xl font-extrabold text-indigo-400 mt-2">{latest_balance}</div>
<div class="text-xs text-slate-400 mt-1">Netto-Differenz</div>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl overflow-hidden">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Verlauf (Letzte 30 Tage)</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="text-xs uppercase bg-slate-800/60 text-slate-400">
<tr>
<th class="py-3 px-4 rounded-l-lg">Datum</th>
<th class="py-3 px-4">Aufnahme</th>
<th class="py-3 px-4">Protein</th>
<th class="py-3 px-4">KH</th>
<th class="py-3 px-4">Fett</th>
<th class="py-3 px-4">Garmin</th>
<th class="py-3 px-4">Bilanz</th>
<th class="py-3 px-4 rounded-r-lg">Schritte</th>
</tr>
</thead>
<tbody>
{''.join(nutrition_rows_html)}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
"""
out_nutrition_html = html_dir / "nutrition.html"
out_nutrition_html.write_text(nutrition_html, encoding="utf-8")
print(f"Rendered HTML Dashboard: {out_nutrition_html}")
# 2. Sleep HTML Dashboard
sleep_days = con.execute(
"""
SELECT
log_date,
total_sleep_hours,
nap_count,
night_sleep_minutes,
nap_sleep_minutes,
total_sessions
FROM v_sleep_daily
WHERE subject = 'mila'
ORDER BY log_date DESC
LIMIT 30
"""
).fetchall()
latest_s = sleep_days[0] if sleep_days else None
latest_tot_h = f"{latest_s[1]:.2f} h" if latest_s else "0 h"
latest_night_str = f"{latest_s[3] // 60} h {latest_s[3] % 60:02d} min" if latest_s else "—"
latest_nap_str = f"{latest_s[2]} Naps ({latest_s[4]} min)" if latest_s else "—"
# Compute 7-day average total sleep hours
avg_sleep = con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2)
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
avg_sleep_h = f"{avg_sleep[0]:.2f} h" if avg_sleep and avg_sleep[0] is not None else "—"
sleep_rows_html = []
for r in sleep_days:
d_str = str(r[0])
tot = f"{r[1]:.2f} h"
night_h = f"{r[3] // 60} h {r[3] % 60:02d} min"
nap_info = f"{r[2]} ({r[4]} min)"
sleep_rows_html.append(f"""
<tr class="border-b border-slate-700/50 hover:bg-slate-800/40 transition">
<td class="py-3 px-4 font-mono font-medium text-slate-200">{d_str}</td>
<td class="py-3 px-4 font-bold text-purple-400">{tot}</td>
<td class="py-3 px-4 text-sky-400 font-semibold">{night_h}</td>
<td class="py-3 px-4 text-emerald-400">{nap_info}</td>
</tr>
""")
sleep_html = f"""<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mila — Schlafprotokoll</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen p-4 sm:p-8 font-sans antialiased selection:bg-purple-500 selection:text-white">
<div class="max-w-5xl mx-auto space-y-6">
<!-- Header -->
<header class="flex flex-col sm:flex-row sm:items-center justify-between pb-6 border-b border-slate-800 gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight bg-gradient-to-r from-purple-400 via-pink-400 to-indigo-300 bg-clip-text text-transparent">
Mila — Schlafprotokoll
</h1>
<p class="text-sm text-slate-400 mt-1">Deterministische Schlafdauer & Wachfenster via DuckDB</p>
</div>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-900 border border-slate-800 text-xs text-slate-400">
<span class="w-2 h-2 rounded-full bg-purple-500 animate-pulse"></span>
<span>Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')}</span>
</div>
</header>
<!-- Key Metrics Grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">24h Schlaf</div>
<div class="text-3xl font-extrabold text-purple-400 mt-2">{latest_tot_h}</div>
<div class="text-xs text-slate-400 mt-1">Letzter Tag</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Nachtschlaf</div>
<div class="text-3xl font-extrabold text-sky-400 mt-2">{latest_night_str}</div>
<div class="text-xs text-slate-400 mt-1">Hauptschlafphase</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Tagesschläfchen</div>
<div class="text-2xl font-extrabold text-emerald-400 mt-2">{latest_nap_str}</div>
<div class="text-xs text-slate-400 mt-1">Tagsüber</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">7-Tage-Schnitt</div>
<div class="text-3xl font-extrabold text-pink-400 mt-2">{avg_sleep_h}</div>
<div class="text-xs text-slate-400 mt-1">Altersbereich: 12-16 h</div>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl overflow-hidden">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Schlafhistorie (Letzte 30 Tage)</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="text-xs uppercase bg-slate-800/60 text-slate-400">
<tr>
<th class="py-3 px-4 rounded-l-lg">Datum</th>
<th class="py-3 px-4">24h Gesamtschlaf</th>
<th class="py-3 px-4">Nachtschlaf</th>
<th class="py-3 px-4 rounded-r-lg">Tagesschläfchen (Dauer)</th>
</tr>
</thead>
<tbody>
{''.join(sleep_rows_html)}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
"""
out_sleep_html = html_dir / "sleep.html"
out_sleep_html.write_text(sleep_html, encoding="utf-8")
print(f"Rendered HTML Dashboard: {out_sleep_html}")
def main():
parser = argparse.ArgumentParser(description="Render Markdown and HTML views from DuckDB Datastore")
parser.add_argument("--wiki-dir", help="Path to knowledge-wiki repository")
parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory")
parser.add_argument("--html-dir", help="Path to output HTML dashboards")
parser.add_argument("--no-md", action="store_true", help="Skip Markdown rendering")
parser.add_argument("--no-html", action="store_true", help="Skip HTML dashboard rendering")
args = parser.parse_args()
default_wiki, default_data = find_default_dirs()
wiki_dir = Path(args.wiki_dir) if args.wiki_dir else default_wiki
data_dir = Path(args.data_dir) if args.data_dir else default_data
html_dir = Path(args.html_dir) if args.html_dir else (wiki_dir / "public" / "family")
engine = DatastoreEngine(data_dir)
if not args.no_md:
render_nutrition_views(engine, wiki_dir)
render_sleep_view(engine, wiki_dir)
if not args.no_html:
render_html_dashboards(engine, html_dir)
if __name__ == "__main__":
main()