feat(datastore): add relative --since duration flag and overnight sleep test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user