feat(datastore): add relative --since duration flag and overnight sleep test

This commit is contained in:
Moritz Graf
2026-10-09 09:25:04 +02:00
parent 0489ef7d62
commit 77a0e56d96
3 changed files with 147 additions and 1 deletions
+8
View File
@@ -49,6 +49,7 @@ Logs a point or interval event.
- For `nutrition.intake`: `{"item": "...", "qty": 200, "unit": "g"|"ml", "protein_g": ..., "kcal": ...}` (or `{"food_id": "oatly-haferdrink", "qty": 200, "unit": "ml"}`).
- For `sleep.session`: `{"type": "nap"|"night", "location": "bett"|"auto"|"kinderwagen"}`.
- `start` (string, optional): ISO 8601 timestamp (defaults to current time).
- `since` (string, optional): Relative start offset backwards from now (e.g. `'5m'`, `'15m'`, `'1h'`). Use for "schläft seit X Min" to avoid LLM mental math!
- `end` (string, optional): ISO 8601 timestamp. Leave empty for open sleep intervals!
- `meal` (string, optional): e.g. `'Frühstück'`, `'Mittagessen'`, `'Snack'`.
- `estimated` (boolean, optional): Set to `true` if portions or nutrients are estimated.
@@ -105,6 +106,13 @@ uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py ad
--actor agent:hebamme \
--payload '{"type": "nap", "location": "kinderwagen"}'
# Start Mila's nap with relative time offset (e.g. asleep since 15 minutes)
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add sleep.session \
--subject mila \
--actor agent:hebamme \
--since "15m" \
--payload '{"type": "nap", "location": "bett"}'
# Stop Mila's nap (closes open interval)
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
--subject mila
+48 -1
View File
@@ -44,6 +44,41 @@ def generate_ulid() -> str:
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def parse_duration(val: str) -> datetime.timedelta:
"""Parses duration strings like '15m', '15 min', '1h', '1h30m', '1.5h' into timedelta."""
s = val.strip().lower()
if not s:
raise ValueError("Empty duration string")
# Decimal hours like "1.5h" or "1.5 stunden"
m_dec_h = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:h|stunden?)$", s)
if m_dec_h:
return datetime.timedelta(hours=float(m_dec_h.group(1)))
# Decimal minutes like "2.5m" or "15 min"
m_dec_m = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:m|min|minuten?)$", s)
if m_dec_m:
return datetime.timedelta(minutes=float(m_dec_m.group(1)))
# Composite like "1h 30m" or "1h30min" or "45s"
m_comp = re.fullmatch(
r"^(?:(\d+)\s*(?:h|stunden?))?\s*(?:(\d+)\s*(?:m|min|minuten?))?\s*(?:(\d+)\s*(?:s|sek|sekunden?))?$",
s,
)
if m_comp and any(m_comp.groups()):
h = int(m_comp.group(1) or 0)
m = int(m_comp.group(2) or 0)
sec = int(m_comp.group(3) or 0)
return datetime.timedelta(hours=h, minutes=m, seconds=sec)
# Pure number without unit: default to minutes
if s.isdigit():
return datetime.timedelta(minutes=int(s))
raise ValueError(f"Unrecognized duration format: '{val}'. Expected formats: '15m', '15 min', '1h', '1h30m'.")
def find_data_dir() -> Path:
"""Discovers the knowledge-wiki data directory."""
if os.environ.get("DATASTORE_DIR"):
@@ -279,6 +314,7 @@ class DatastoreEngine:
payload: Dict[str, Any],
start: Optional[str] = None,
end: Optional[str] = None,
since: Optional[str] = None,
estimated: bool = False,
meal: Optional[str] = None,
note: Optional[str] = None,
@@ -289,8 +325,15 @@ class DatastoreEngine:
now_dt = datetime.datetime.now(datetime.timezone.utc)
now_iso = now_dt.isoformat()
if since and start:
raise ValueError("Cannot specify both 'since' and 'start'. Use 'since' for relative times or 'start' for absolute times.")
# Parse or default start time
if not start:
if since:
delta = parse_duration(since)
start_dt = (now_dt - delta).astimezone(BERLIN_TZ)
start_iso = start_dt.isoformat()
elif not start:
start_dt = now_dt.astimezone(BERLIN_TZ)
start_iso = start_dt.isoformat()
else:
@@ -713,6 +756,7 @@ def run_mcp_server():
"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)"},
"since": {"type": "string", "description": "Relative start offset backwards from now (e.g. '5m', '15m', '1h'). Use when logging events that started X minutes ago without computing absolute timestamps."},
"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"},
@@ -832,6 +876,7 @@ def run_mcp_server():
payload=args["payload"],
start=args.get("start"),
end=args.get("end"),
since=args.get("since"),
estimated=args.get("estimated", False),
meal=args.get("meal"),
note=args.get("note"),
@@ -906,6 +951,7 @@ def run_cli():
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("--since", help="Relative start offset backwards from now (e.g. '5m', '15m', '1h')")
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")
@@ -944,6 +990,7 @@ def run_cli():
payload=payload,
start=args.start,
end=args.end,
since=args.since,
meal=args.meal,
note=args.note,
estimated=args.estimated,
+91
View File
@@ -293,3 +293,94 @@ def test_garmin_daily_and_daily_balance(temp_datastore: DatastoreEngine):
assert row["balance_kcal"] == -744.0 # 2146 - 2890
assert row["balance_status"] == "Defizit"
def test_add_event_with_since(temp_datastore: DatastoreEngine):
"""Verifies that relative offsets like '15m' calculate start time deterministically in Berlin timezone."""
from zoneinfo import ZoneInfo
berlin_tz = ZoneInfo("Europe/Berlin")
before = datetime.datetime.now(berlin_tz)
res = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
since="15m",
payload={"type": "nap", "location": "bett"},
)
after = datetime.datetime.now(berlin_tz)
assert res["status"] == "success"
event = res["event"]
assert event["end"] is None
start_dt = datetime.datetime.fromisoformat(event["start"])
assert start_dt.tzinfo is not None
# Verify Berlin timezone offset (+02:00 in CEST or +01:00 in CET)
assert start_dt.utcoffset() == before.utcoffset()
# The logged start must be ~15 minutes before the test run
expected_approx = before - datetime.timedelta(minutes=15)
assert abs((start_dt - expected_approx).total_seconds()) < 5
# Test error if both start and since are provided
with pytest.raises(ValueError, match="Cannot specify both 'since' and 'start'"):
temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-06T12:00:00+02:00",
since="10m",
payload={"item": "Snack", "qty": 10},
)
# Test invalid since format
with pytest.raises(ValueError, match="Unrecognized duration format"):
temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
since="invalid_format",
payload={"item": "Snack", "qty": 10},
)
def test_sleep_over_midnight_attribution(temp_datastore: DatastoreEngine):
"""Verifies that overnight sleep (Wed evening -> Thu morning) stays attributed to Wednesday."""
# Sleep starts Wednesday 19:15
res_start = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-07T19:15:00+02:00",
payload={"type": "night", "location": "bett"},
)
assert res_start["status"] == "success"
# Sleep ends Thursday 07:00
res_stop = temp_datastore.stop_event(
kind="sleep.session",
subject="mila",
end_time="2026-10-08T07:00:00+02:00",
)
assert res_stop["status"] == "success"
# Duration: 11 hours 45 min = 705 minutes
assert res_stop["duration_minutes"] == 705
# Check v_sleep view: log_date must be the day it STARTED (Wednesday 2026-10-07)
rows = temp_datastore.query("SELECT log_date, duration_minutes, sleep_type FROM v_sleep WHERE subject = 'mila'")
assert len(rows) == 1
assert rows[0]["log_date"] == "2026-10-07"
assert rows[0]["sleep_type"] == "night"
assert rows[0]["duration_minutes"] == 705
# Check daily summary for Wednesday: must contain the full 11.75 hours night sleep
wed_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-07")
assert "sleep" in wed_summary
assert wed_summary["sleep"]["night_sleep_minutes"] == 705
assert wed_summary["sleep"]["total_sleep_hours"] == 11.75
# Check daily summary for Thursday: must NOT contain Wednesday night's sleep
thu_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-08")
assert "sleep" not in thu_summary or thu_summary["sleep"] is None