Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6a32697f | ||
|
|
9f2c2c33d3 | ||
|
|
6b3fbfdbb5 | ||
|
|
8bdd06e598 | ||
|
|
4f72e4d71b | ||
|
|
7b5d039323 | ||
|
|
fe4edcc217 | ||
|
|
3e9e12bbc5 | ||
|
|
7b9b3ff0eb | ||
|
|
191c8e9143 | ||
|
|
f5b5deaae7 | ||
|
|
545752fa60 | ||
|
|
e9d2c59eed | ||
|
|
63497765b0 | ||
|
|
88c42aa1e5 | ||
|
|
bcb04711c0 | ||
|
|
694d0c5072 | ||
|
|
1ccab5e522 | ||
|
|
fe21d01a3f | ||
|
|
1babaab1e2 | ||
|
|
3abfa18182 | ||
|
|
967909c3a7 | ||
|
|
80e38ab64a | ||
|
|
786ce47bd4 | ||
|
|
88d49f85e5 | ||
|
|
c9cbc56441 | ||
|
|
6a93539a97 | ||
|
|
77a0e56d96 | ||
|
|
0489ef7d62 | ||
|
|
2bde04617f | ||
|
|
7336f7bfff |
@@ -84,6 +84,12 @@ Instant chart and diagram generation for chat and Telegram.
|
|||||||
- **Delivery:** Generates local PNGs and outputs `MEDIA:<path>` for native Telegram image rendering.
|
- **Delivery:** Generates local PNGs and outputs `MEDIA:<path>` for native Telegram image rendering.
|
||||||
- **Tools:** Standalone CLI at `scripts/chart.py`.
|
- **Tools:** Standalone CLI at `scripts/chart.py`.
|
||||||
|
|
||||||
|
### [create-website](skills/create-website/SKILL.md)
|
||||||
|
Creative Web Director and Senior Frontend Engineer for single-page applications and interactive showcases.
|
||||||
|
- **Features:** Builds fancy, responsive, zero-build SPAs (Tailwind CSS Play CDN, Alpine.js reactivity, Lucide icons, Google Fonts) with anti-AI-slop design archetypes (Dark Obsidian, Editorial Warmth, Neo-Brutalism, Modern Clean SaaS).
|
||||||
|
- **Hosting:** Direct validation and upload to `https://hosting.haumdaucher.de/showcase/...` via the `hosting` skill pipeline, with native Telegram document delivery and QR code generation.
|
||||||
|
- **Tools:** Standalone validation & publisher CLI at `scripts/publish_website.py`.
|
||||||
|
|
||||||
### [grill-me](skills/grill-me/SKILL.md)
|
### [grill-me](skills/grill-me/SKILL.md)
|
||||||
Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code.
|
Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code.
|
||||||
- **Features:** Decision tree & frontier exploration, Telegram/mobile-optimized batching (1-3 questions per round), facts-first environment research, anti-sycophancy challenge, and final specification/ADR synthesis.
|
- **Features:** Decision tree & frontier exploration, Telegram/mobile-optimized batching (1-3 questions per round), facts-first environment research, anti-sycophancy challenge, and final specification/ADR synthesis.
|
||||||
@@ -95,6 +101,13 @@ Builds, previews, validates and publishes Zwift `.zwo` workout files (bike-only)
|
|||||||
- **Tools:** CLI at `scripts/zwo.py` (`build`, `from-template`, `validate`, `parse`, `render`, `templates`) + builder at `scripts/zwo_builder.py`.
|
- **Tools:** CLI at `scripts/zwo.py` (`build`, `from-template`, `validate`, `parse`, `render`, `templates`) + builder at `scripts/zwo_builder.py`.
|
||||||
- **Integration:** Fitness agent / Tacx Flux 2; synergistic with `garmin-workout` (one spec, two targets).
|
- **Integration:** Fitness agent / Tacx Flux 2; synergistic with `garmin-workout` (one spec, two targets).
|
||||||
|
|
||||||
|
### [datastore](skills/datastore/SKILL.md)
|
||||||
|
Structured personal data layer for OpenClaw powered by in-memory DuckDB and git-backed JSONL in `knowledge-wiki/data/`.
|
||||||
|
- **Purpose:** Eliminates LLM arithmetic errors across nutrition intake, baby sleep tracking, and daily caloric balances (ADR 0004).
|
||||||
|
- **Features:** Unified time-series events (`nutrition.intake`, `sleep.session` with open intervals `end=NULL`, `feeding.bottle`), entity catalogs (`foods`, `recipes`, `places`, `people`), dual-engine geospatial radius queries (DuckDB spatial + Haversine fallback), and nightly Garmin Connect mirror.
|
||||||
|
- **Presentation:** Automatically renders Markdown logs in `entities/` and responsive HTML dashboards on `hosting.haumdaucher.de/family/`.
|
||||||
|
- **Tools:** Standalone MCP server and CLI at `scripts/datastore.py`, `scripts/garmin_sync.py`, and `scripts/render_views.py`.
|
||||||
|
|
||||||
### [skill-creator](skills/skill-creator/SKILL.md)
|
### [skill-creator](skills/skill-creator/SKILL.md)
|
||||||
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
|
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
---
|
||||||
|
name: create-website
|
||||||
|
description: Build and host fancy, production-grade, interactive single-page websites, SPAs, and HTML showcases using modern Zero-Build technologies (Tailwind CSS, Alpine.js, Lucide Icons). Use when the user asks to create, build, design, or show a website, landing page, interactive calculator, showcase, recipe app, dashboard, or web tool.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Website & Interactive Single-Page Applications 🌐✨
|
||||||
|
|
||||||
|
This skill empowers OpenClaw to act as a **Creative Web Director and Senior Frontend Engineer**. It generates visually stunning, responsive, fully interactive single-page applications (SPAs) contained within a single HTML file—without requiring complex build steps or Node.js compilers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Core Philosophy: The Zero-Build Reactive Engine
|
||||||
|
|
||||||
|
1. **Self-Contained Single-File**: HTML markup, Tailwind CSS utility styling, Alpine.js reactivity, and Lucide icons reside in a single standalone file.
|
||||||
|
2. **Zero-Build Latency**: Runs directly in the browser via fast CDNs. Zero compile time, 100% reliable execution across desktop, tablet, and mobile (iOS Safari, Telegram in-app browser).
|
||||||
|
3. **Anti-AI-Slop Aesthetics**: Reject generic AI defaults (no plain Inter font, no purple-to-blue gradients, no boring centered 3-card monotony). Every site must have an intentional aesthetic direction with strong typography, depth, and micro-interactions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 1. Choose a Visual Archetype
|
||||||
|
|
||||||
|
Before writing code, commit strictly to one of the 4 design archetypes (detailed in [references/archetypes.md](references/archetypes.md)):
|
||||||
|
|
||||||
|
* **Dark Obsidian & Cyber-Luxury**: Deep dark canvas (`#090A0F`), glassmorphism cards (`backdrop-blur-xl border border-white/10`), electric cyan/violet/emerald glows. Fonts: *Syne* + *Plus Jakarta Sans*. (Ideal for tech showcases, tools, AI, fitness trackers).
|
||||||
|
* **Editorial Warmth & Swiss Typographic**: Linen/ivory background (`#FDFBF7`), hairline borders (`border-stone-200`), warm terracotta/forest green accents. Fonts: *Fraunces* (Serif) + *Instrument Sans*. (Ideal for research dossiers, recipes, long-form stories).
|
||||||
|
* **Neo-Brutalism & High-Energy**: High contrast, thick black borders (`border-2 border-black`), offset hard drop shadows (`shadow-[4px_4px_0px_0px_#000]`), vivid canary yellow and lime accents. Fonts: *Space Grotesk* + *JetBrains Mono*. (Ideal for events, party invites, challenges, games).
|
||||||
|
* **Modern Clean SaaS & Dynamic Dashboard**: Crisp slate canvas, automatic dark/light mode toggle via Alpine, KPI cards, filterable tables. Fonts: *Outfit* + *Plus Jakarta Sans*. (Ideal for data apps, comparisons, calculators).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 2. The Standard CDN Skeleton
|
||||||
|
|
||||||
|
Use this bulletproof template structure for all generated websites:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" x-data="{ darkMode: localStorage.getItem('theme') === 'dark' }" :class="{ 'dark': darkMode }">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Titel der Website</title>
|
||||||
|
|
||||||
|
<!-- 1. Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- 2. Tailwind CSS v3 Play CDN -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
heading: ['Syne', 'sans-serif'],
|
||||||
|
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'monospace'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- 3. Alpine.js for snappy reactivity -->
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- 4. Lucide Icons CDN -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
|
||||||
|
<!-- 5. Optional Confetti for Micro-Delight -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#090A0F] text-zinc-100 font-sans min-h-screen flex flex-col antialiased selection:bg-cyan-500/30 selection:text-cyan-200">
|
||||||
|
|
||||||
|
<!-- Main Application Content (Tabs, Filters, Interactive Cards, Calculators) -->
|
||||||
|
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-8 md:py-16">
|
||||||
|
<!-- Component Code Here -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 6. Lucide Icons Initialization (Always at the end of body) -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 3. Add Interactivity with Alpine.js
|
||||||
|
|
||||||
|
Review [references/alpine-patterns.md](references/alpine-patterns.md) for pre-built patterns:
|
||||||
|
* **Dynamic Tabs**: Seamless view switching without page reloads.
|
||||||
|
* **Instant Search & Filter**: Real-time filtering of cards/recipes/items based on text input or category pills.
|
||||||
|
* **Reactive Calculators / Sliders**: Live computation (portions, prices, nutrition, splits) with instant UI updates.
|
||||||
|
* **Modal Dialogs & Drawers**: Smooth transitions with backdrop blur and escape key handling.
|
||||||
|
* **Celebration Effects**: Triggering confetti on successful calculations or milestones (`confetti({ particleCount: 80, spread: 70 })`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 4. Publishing & Sharing Workflow
|
||||||
|
|
||||||
|
Once the website HTML has been generated and saved locally (e.g. to `/tmp/websites/<slug>.html`), publish it to public hosting:
|
||||||
|
|
||||||
|
### Step 1: Run the Publisher Script
|
||||||
|
```bash
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/create-website/scripts/publish_website.py \
|
||||||
|
--file /tmp/websites/<slug>.html \
|
||||||
|
--category showcase \
|
||||||
|
--slug <slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates the HTML and uploads it to MinIO, returning the public HTTPS URL:
|
||||||
|
`https://hosting.haumdaucher.de/showcase/{YYYYMMDD}-{slug}.html`
|
||||||
|
|
||||||
|
### Step 2: Present to User in Telegram
|
||||||
|
Always deliver both the clickable link and the local file for offline access:
|
||||||
|
```markdown
|
||||||
|
🚀 **Deine Website ist live!**
|
||||||
|
🔗 https://hosting.haumdaucher.de/showcase/20260928-ninja-creami-calc.html
|
||||||
|
|
||||||
|
*(Die Datei ist zum lokalen Öffnen ebenfalls beigefügt)*
|
||||||
|
MEDIA:/tmp/websites/<slug>.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Offer QR Code
|
||||||
|
Immediately trigger `ask_user` to offer generating a scannable QR code:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question": "Möchtest du für diesen Link einen QR-Code generieren?",
|
||||||
|
"options": ["Ja, QR-Code erstellen", "Nein, danke"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If approved, execute the `qr-code` skill:
|
||||||
|
```bash
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/qr-code/scripts/generate_qr.py "https://hosting.haumdaucher.de/showcase/20260928-ninja-creami-calc.html"
|
||||||
|
```
|
||||||
|
and send via `MEDIA:<path>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Quality Gate & Anti-Slop Checklist
|
||||||
|
Always check the site against [references/anti-slop-checklist.md](references/anti-slop-checklist.md) before publishing:
|
||||||
|
1. Are font pairings distinct and imported via Google Fonts?
|
||||||
|
2. Are all buttons responsive with hover and active states (`active:scale-95`)?
|
||||||
|
3. Does the layout scale gracefully on small mobile screens (`grid-cols-1 md:grid-cols-2 lg:grid-cols-3`)?
|
||||||
|
4. Are Lucide icons properly rendered via `lucide.createIcons()`?
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Alpine.js Core Patterns for Single-Page Applications ⚡
|
||||||
|
|
||||||
|
These zero-build Alpine.js patterns provide reactive state management directly in HTML. Use these snippets to make your single-page websites snappy, interactive, and responsive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dark/Light Mode Switcher with Persistence
|
||||||
|
Place this in `<html x-data="{ darkMode: localStorage.getItem('theme') === 'dark' }" :class="{ 'dark': darkMode }">`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button
|
||||||
|
@click="darkMode = !darkMode; localStorage.setItem('theme', darkMode ? 'dark' : 'light')"
|
||||||
|
class="p-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all cursor-pointer"
|
||||||
|
title="Toggle Theme">
|
||||||
|
<i x-show="!darkMode" data-lucide="moon" class="w-5 h-5"></i>
|
||||||
|
<i x-show="darkMode" data-lucide="sun" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dynamic Tab Navigation
|
||||||
|
Switch between multiple views without page reloads:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div x-data="{ activeTab: 'overview' }" class="w-full">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex gap-2 p-1.5 bg-zinc-100 dark:bg-zinc-900 rounded-xl max-w-md mx-auto mb-8 border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'overview'"
|
||||||
|
:class="activeTab === 'overview' ? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'"
|
||||||
|
class="flex-1 py-2 px-4 rounded-lg font-medium text-sm transition-all flex items-center justify-center gap-2">
|
||||||
|
<i data-lucide="layout-grid" class="w-4 h-4"></i> Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'calculator'"
|
||||||
|
:class="activeTab === 'calculator' ? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'"
|
||||||
|
class="flex-1 py-2 px-4 rounded-lg font-medium text-sm transition-all flex items-center justify-center gap-2">
|
||||||
|
<i data-lucide="calculator" class="w-4 h-4"></i> Calculator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Contents with Transitions -->
|
||||||
|
<div x-show="activeTab === 'overview'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
|
||||||
|
<!-- Overview View Content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="activeTab === 'calculator'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
|
||||||
|
<!-- Calculator View Content -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Instant Search & Category Filter
|
||||||
|
Real-time filtering of cards or list items:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div x-data="{
|
||||||
|
search: '',
|
||||||
|
activeCategory: 'all',
|
||||||
|
items: [
|
||||||
|
{ title: 'Pistazien Gelato', category: 'creami', protein: '28g', calories: 340, desc: 'Reichhaltiges Gelato mit echter sizilianischer Pistazienpaste.' },
|
||||||
|
{ title: 'Protein Erdbeer Sorbet', category: 'protein', protein: '35g', calories: 210, desc: 'Frisches Erdbeersorbet mit Clear Whey Isolat.' },
|
||||||
|
{ title: 'Mango Maracuja Slush', category: 'fruit', protein: '2g', calories: 140, desc: 'Perfekte Sommererfrischung mit Mango-Chunks.' }
|
||||||
|
],
|
||||||
|
get filteredItems() {
|
||||||
|
return this.items.filter(item => {
|
||||||
|
const matchesSearch = item.title.toLowerCase().includes(this.search.toLowerCase()) || item.desc.toLowerCase().includes(this.search.toLowerCase());
|
||||||
|
const matchesCategory = this.activeCategory === 'all' || item.category === this.activeCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<!-- Search & Pills -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<i data-lucide="search" class="w-4 h-4 absolute left-3.5 top-3.5 text-zinc-400"></i>
|
||||||
|
<input
|
||||||
|
x-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Rezepte durchsuchen..."
|
||||||
|
class="w-full pl-10 pr-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
<template x-for="cat in ['all', 'creami', 'protein', 'fruit']" :key="cat">
|
||||||
|
<button
|
||||||
|
@click="activeCategory = cat"
|
||||||
|
:class="activeCategory === cat ? 'bg-cyan-500 text-white font-semibold' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200'"
|
||||||
|
class="px-4 py-2 rounded-xl text-xs uppercase tracking-wider capitalize whitespace-nowrap transition-all cursor-pointer"
|
||||||
|
x-text="cat === 'all' ? 'Alle Rezepte' : cat">
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<template x-for="item in filteredItems" :key="item.title">
|
||||||
|
<div class="p-6 rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 hover:border-cyan-500/50 shadow-sm hover:shadow-xl transition-all">
|
||||||
|
<h3 class="text-lg font-bold text-zinc-900 dark:text-white mb-2" x-text="item.title"></h3>
|
||||||
|
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4" x-text="item.desc"></p>
|
||||||
|
<div class="flex justify-between items-center text-xs font-semibold text-cyan-600 dark:text-cyan-400">
|
||||||
|
<span x-text="item.protein + ' Protein'"></span>
|
||||||
|
<span x-text="item.calories + ' kcal'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Live Reactive Slider & Calculator
|
||||||
|
Instant calculations with automatic UI updates:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div x-data="{
|
||||||
|
pints: 1,
|
||||||
|
baseMilk: 300,
|
||||||
|
baseWhey: 35,
|
||||||
|
basePistachio: 30,
|
||||||
|
get milk() { return this.baseMilk * this.pints; },
|
||||||
|
get whey() { return this.baseWhey * this.pints; },
|
||||||
|
get pistachio() { return this.basePistachio * this.pints; },
|
||||||
|
get proteinTotal() { return Math.round(this.whey * 0.8 + (this.milk * 0.034)); },
|
||||||
|
celebrate() {
|
||||||
|
if (typeof confetti === 'function') {
|
||||||
|
confetti({ particleCount: 75, spread: 60, origin: { y: 0.8 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}" class="p-6 rounded-2xl bg-zinc-900 border border-white/10 text-white">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-xl font-bold font-heading">Portions-Rechner</h3>
|
||||||
|
<span class="px-3 py-1 bg-cyan-500/20 text-cyan-400 rounded-full text-xs font-semibold" x-text="pints + (pints === 1 ? ' Pint' : ' Pints')"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-xs uppercase tracking-wider text-zinc-400 mb-2">Anzahl Ninja CREAMi Deluxe Pints (680ml)</label>
|
||||||
|
<input type="range" min="1" max="4" step="1" x-model.number="pints" class="w-full accent-cyan-400 cursor-pointer">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Computed Ingredients -->
|
||||||
|
<div class="space-y-3 mb-6">
|
||||||
|
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
|
||||||
|
<span class="text-zinc-400">Milch 1.5% oder Hafermilch</span>
|
||||||
|
<span class="font-bold text-white" x-text="milk + ' ml'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
|
||||||
|
<span class="text-zinc-400">Proteinpulver (Whey/Casein)</span>
|
||||||
|
<span class="font-bold text-cyan-400" x-text="whey + ' g'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
|
||||||
|
<span class="text-zinc-400">Pistazienmus (100%)</span>
|
||||||
|
<span class="font-bold text-emerald-400" x-text="pistachio + ' g'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="celebrate()" class="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-xl font-bold text-sm hover:brightness-110 active:scale-95 transition-all">
|
||||||
|
Zutaten vorbereitet! 🎉
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Modal Dialog with Escape & Backdrop Blur
|
||||||
|
Accessible modal overlay:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div x-data="{ open: false }">
|
||||||
|
<button @click="open = true" class="px-4 py-2 bg-zinc-900 text-white rounded-xl text-sm font-medium">
|
||||||
|
Details öffnen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
@keydown.escape.window="open = false"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
style="display: none;">
|
||||||
|
|
||||||
|
<div
|
||||||
|
@click.outside="open = false"
|
||||||
|
class="bg-white dark:bg-zinc-900 rounded-2xl max-w-lg w-full p-6 border border-zinc-200 dark:border-zinc-800 shadow-2xl"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100">
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">Modal Titel</h3>
|
||||||
|
<button @click="open = false" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200">
|
||||||
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-6">
|
||||||
|
Hier befindet sich detaillierter Inhalt mit Reaktivität oder Formularen.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="open = false" class="px-4 py-2 text-sm text-zinc-500 hover:text-zinc-800 dark:hover:text-white">Schließen</button>
|
||||||
|
<button @click="open = false" class="px-4 py-2 text-sm font-medium bg-cyan-500 text-white rounded-xl">Bestätigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Anti-AI-Slop Quality Checklist 🛡️
|
||||||
|
|
||||||
|
Before finalizing and publishing any website or HTML artifact, verify every item on this checklist. This ensures the output feels designed by a human senior product designer rather than a generic LLM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Forbidden Trappings of "AI Slop"
|
||||||
|
1. **Never use generic default fonts**:
|
||||||
|
* 🚫 No raw Inter, Roboto, Arial, or unstyled system-ui.
|
||||||
|
* ✅ Always import characterful fonts (e.g. *Syne*, *Cabinet Grotesk*, *Fraunces*, *Space Grotesk*, *Plus Jakarta Sans*).
|
||||||
|
2. **Never use the generic purple-to-indigo gradient**:
|
||||||
|
* 🚫 `bg-gradient-to-r from-purple-600 to-indigo-600` on white background.
|
||||||
|
* ✅ Use intentional palettes: deep obsidian with electric cyan accents, warm ivory with terracotta, neo-brutalist yellow with black borders, or slate with emerald glow.
|
||||||
|
3. **Never generate static, boring centered 3-card rows**:
|
||||||
|
* 🚫 Three identical white boxes with light gray text centered on a plain gray canvas.
|
||||||
|
* ✅ Vary card sizes, emphasize a primary card with a border glow or badge, use grid spans (`md:col-span-2`), or add asymmetry.
|
||||||
|
4. **Never forget interactive micro-interactions**:
|
||||||
|
* 🚫 Dead buttons that don't react to hover or click.
|
||||||
|
* ✅ Always add `hover:-translate-y-0.5 hover:shadow-lg transition-all duration-200` and `active:scale-95`.
|
||||||
|
5. **Never rely on broken external images**:
|
||||||
|
* 🚫 Using fake URLs like `https://example.com/image.jpg` or placeholder services that fail.
|
||||||
|
* ✅ Use SVG patterns, Lucide icons, gradient mesh backgrounds, or reliable Unsplash source links with explicit fallback styling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Mandatory Pre-Flight Verification
|
||||||
|
|
||||||
|
| Check | Requirement | Verified? |
|
||||||
|
|---|---|:---:|
|
||||||
|
| **Viewport & Encoding** | `<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">` | [ ] |
|
||||||
|
| **Tailwind CDN** | `<script src="https://cdn.tailwindcss.com"></script>` included in `<head>` | [ ] |
|
||||||
|
| **Alpine.js CDN** | `<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>` in `<head>` | [ ] |
|
||||||
|
| **Lucide Icons** | `<script src="https://unpkg.com/lucide@latest"></script>` + `lucide.createIcons()` at bottom of `<body>` | [ ] |
|
||||||
|
| **Google Fonts** | Preconnect + Google Font `<link>` + custom `<style>` setting `font-family` | [ ] |
|
||||||
|
| **Touch Targets** | Buttons and clickable elements have at least `py-2.5 px-4` and `cursor-pointer` | [ ] |
|
||||||
|
| **Dark Mode Consistency** | If dark mode is supported, all cards, borders, text, and inputs have `dark:` variants | [ ] |
|
||||||
|
| **Mobile Responsiveness** | Layout uses `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` and responsive paddings (`p-4 md:p-8`) | [ ] |
|
||||||
|
| **Content Richness** | Real, realistic domain content (no repeated "Lorem Ipsum" filler) | [ ] |
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Design Archetypes & Visual Systems 🎨
|
||||||
|
|
||||||
|
This document defines the 4 core design archetypes used by the `create-website` skill. Every generated website or single-page application must commit fully to one of these visual directions to ensure high aesthetic distinction and eliminate generic AI aesthetics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dark Obsidian & Cyber-Luxury 🌌
|
||||||
|
*Best for: Tech products, developer tools, AI showcases, crypto/finance, dark-mode dashboards, interactive apps.*
|
||||||
|
|
||||||
|
### Google Fonts Embed
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
h1, h2, h3, .font-heading { font-family: 'Syne', sans-serif; }
|
||||||
|
code, pre, .font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Palette & Classes
|
||||||
|
* **Canvas Background**: `bg-[#090A0F]` with subtle radial gradients:
|
||||||
|
`bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.15),rgba(255,255,255,0))]`
|
||||||
|
* **Card Surface (Glassmorphism)**: `bg-white/[0.03] backdrop-blur-xl border border-white/10 hover:border-white/20 transition-all`
|
||||||
|
* **Secondary Surface**: `bg-[#12151E] border border-white/5`
|
||||||
|
* **Text Hierarchy**: Headings `text-white font-bold tracking-tight`, Body `text-zinc-400`, Subtle `text-zinc-500`
|
||||||
|
* **Accent Highlights**:
|
||||||
|
* Electric Cyan: `text-cyan-400 bg-cyan-500/10 border-cyan-500/20 shadow-[0_0_20px_rgba(6,182,212,0.15)]`
|
||||||
|
* Neon Violet: `text-purple-400 bg-purple-500/10 border-purple-500/20`
|
||||||
|
* Emerald Glow: `text-emerald-400 bg-emerald-500/10 border-emerald-500/20`
|
||||||
|
* **Buttons**:
|
||||||
|
* Primary: `bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium px-5 py-2.5 rounded-xl shadow-lg shadow-cyan-500/20 active:scale-95 transition-all`
|
||||||
|
* Secondary: `bg-white/5 hover:bg-white/10 text-white border border-white/10 px-5 py-2.5 rounded-xl active:scale-95 transition-all`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Editorial Warmth & Swiss Typographic 📰
|
||||||
|
*Best for: In-depth research dossiers, long-form journalism, recipe collections, personal manifestos, architectural notes, knowledge collections.*
|
||||||
|
|
||||||
|
### Google Fonts Embed
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&family=Instrument+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Instrument Sans', sans-serif; }
|
||||||
|
h1, h2, h3, .font-heading { font-family: 'Fraunces', serif; }
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Palette & Classes
|
||||||
|
* **Canvas Background**: Warm Ivory `bg-[#FDFBF7]` or Linen `bg-[#F7F4EE]`
|
||||||
|
* **Card Surface**: Paper White `bg-white border border-stone-200/80 shadow-sm rounded-2xl`
|
||||||
|
* **Text Hierarchy**: Headings `text-stone-900 font-semibold tracking-tight`, Body `text-stone-600 leading-relaxed`, Captions `text-stone-500 text-sm`
|
||||||
|
* **Accent Highlights**:
|
||||||
|
* Heritage Rust / Terracotta: `text-[#C2410C] bg-[#FFF7ED] border-[#FDBA74]`
|
||||||
|
* Deep Forest: `text-[#166534] bg-[#F0FDF4] border-[#BBF7D0]`
|
||||||
|
* Warm Amber: `text-[#B45309] bg-[#FEF3C7] border-[#FDE68A]`
|
||||||
|
* **Styling Rules**:
|
||||||
|
* Generous margins and breathing room (`py-16 md:py-24 max-w-4xl mx-auto`).
|
||||||
|
* Elegant divider rules (`border-t border-stone-200`).
|
||||||
|
* Dropcaps or serif quotes with subtle italic styling (`italic text-stone-700`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Neo-Brutalism & High-Energy ⚡
|
||||||
|
*Best for: Interactive party invites, festival schedules, fitness challenges, hackathon pitches, playful calculators, game companions.*
|
||||||
|
|
||||||
|
### Google Fonts Embed
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Space Grotesk', sans-serif; }
|
||||||
|
code, pre, .font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Palette & Classes
|
||||||
|
* **Canvas Background**: High-contrast Pale Pastel `bg-[#FAF8F5]` or Off-Yellow `bg-[#FEFCE8]`
|
||||||
|
* **Borders & Shadows**:
|
||||||
|
* Signature Shadow: `border-2 border-black shadow-[4px_4px_0px_0px_#000] hover:shadow-[2px_2px_0px_0px_#000] hover:translate-x-[2px] hover:translate-y-[2px] transition-all`
|
||||||
|
* Card: `bg-white border-2 border-black shadow-[5px_5px_0px_0px_#000] rounded-xl p-6`
|
||||||
|
* **Color Accents**:
|
||||||
|
* Electric Canary: `bg-[#FDE047]`
|
||||||
|
* Acid Lime: `bg-[#A3E635]`
|
||||||
|
* Bright Coral: `bg-[#FB7185]`
|
||||||
|
* Cyan Pill: `bg-[#38BDF8]`
|
||||||
|
* **Interactive Elements**:
|
||||||
|
* Badges: `inline-block px-3 py-1 text-xs font-bold uppercase tracking-wider bg-black text-white rounded-md`
|
||||||
|
* Buttons: `bg-[#FDE047] hover:bg-[#FACC15] text-black font-bold px-6 py-3 border-2 border-black shadow-[4px_4px_0px_0px_#000] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all rounded-xl cursor-pointer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modern Clean SaaS & Dynamic Dashboard 📊
|
||||||
|
*Best for: Multi-tab trackers, data analytics, KPI summaries, filterable catalogues, interactive calculators, recipe managers.*
|
||||||
|
|
||||||
|
### Google Fonts Embed
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
h1, h2, h3, .font-heading { font-family: 'Outfit', sans-serif; }
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Palette & Classes
|
||||||
|
* **Canvas Background**: Adaptive Light/Dark:
|
||||||
|
`bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200`
|
||||||
|
* **Card Surface**:
|
||||||
|
`bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm hover:shadow-md transition-all`
|
||||||
|
* **KPI Metrics**:
|
||||||
|
* Value: `text-3xl md:text-4xl font-bold font-heading tracking-tight text-slate-900 dark:text-white`
|
||||||
|
* Label: `text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400 mt-1`
|
||||||
|
* **Interactive Controls**:
|
||||||
|
* Inputs: `bg-slate-100 dark:bg-slate-800 border-none rounded-xl px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500`
|
||||||
|
* Active Tabs: `bg-indigo-600 text-white rounded-lg px-4 py-2 font-medium shadow-sm`
|
||||||
|
* Inactive Tabs: `text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 rounded-lg`
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "boto3>=1.34.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""
|
||||||
|
Publish single-page websites to hosting.haumdaucher.de with validation and clean URL reporting.
|
||||||
|
Integrates with the existing 'hosting' skill publisher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def validate_html(content: str) -> list[str]:
|
||||||
|
"""Basic pre-flight checks to ensure the generated website isn't malformed."""
|
||||||
|
warnings = []
|
||||||
|
content_lower = content.lower()
|
||||||
|
|
||||||
|
if "<!doctype html>" not in content_lower:
|
||||||
|
warnings.append("Missing <!DOCTYPE html> declaration.")
|
||||||
|
if "viewport" not in content_lower:
|
||||||
|
warnings.append("Missing responsive viewport meta tag.")
|
||||||
|
if "cdn.tailwindcss.com" not in content_lower and "tailwindcss" not in content_lower:
|
||||||
|
warnings.append("Tailwind CSS CDN not detected in HTML.")
|
||||||
|
if "alpine" not in content_lower:
|
||||||
|
warnings.append("Alpine.js script not detected (interactivity might be missing).")
|
||||||
|
if "lucide" in content_lower and "lucide.createicons" not in content_lower:
|
||||||
|
warnings.append("Lucide icons included, but 'lucide.createIcons()' initialization is missing.")
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
def resolve_hosting_script() -> Path:
|
||||||
|
"""Finds the publish_file.py script relative to this script or in standard locations."""
|
||||||
|
current_dir = Path(__file__).resolve().parent
|
||||||
|
# Standard relative location in mop-skills
|
||||||
|
sibling_hosting = current_dir.parent.parent / "hosting" / "scripts" / "publish_file.py"
|
||||||
|
if sibling_hosting.exists():
|
||||||
|
return sibling_hosting
|
||||||
|
|
||||||
|
# Fallback in container
|
||||||
|
pod_hosting = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py")
|
||||||
|
if pod_hosting.exists():
|
||||||
|
return pod_hosting
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"Cannot find publish_file.py in {sibling_hosting} or {pod_hosting}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Publish websites to hosting.haumdaucher.de")
|
||||||
|
parser.add_argument("--file", "-f", help="Local HTML file to upload")
|
||||||
|
parser.add_argument("--content", "-c", help="Inline HTML content string")
|
||||||
|
parser.add_argument("--slug", "-s", required=True, help="Slug identifier for URL (e.g. creami-calculator)")
|
||||||
|
parser.add_argument("--category", default="showcase", help="Category folder (default: showcase)")
|
||||||
|
parser.add_argument("--skip-validation", action="store_true", help="Skip HTML quality checks")
|
||||||
|
parser.add_argument("--url-only", action="store_true", help="Print only public URL")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.file and args.content is None:
|
||||||
|
parser.error("You must provide either --file or --content.")
|
||||||
|
|
||||||
|
# Read content for validation
|
||||||
|
html_content = ""
|
||||||
|
if args.file:
|
||||||
|
file_path = Path(args.file)
|
||||||
|
if not file_path.exists():
|
||||||
|
sys.exit(f"Error: File not found: {args.file}")
|
||||||
|
html_content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
html_content = args.content
|
||||||
|
|
||||||
|
# Quality validation
|
||||||
|
if not args.skip_validation:
|
||||||
|
warnings = validate_html(html_content)
|
||||||
|
if warnings:
|
||||||
|
print("⚠️ Pre-flight HTML Warnings:", file=sys.stderr)
|
||||||
|
for w in warnings:
|
||||||
|
print(f" - {w}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Delegate to hosting/scripts/publish_file.py
|
||||||
|
publisher_script = resolve_hosting_script()
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(publisher_script),
|
||||||
|
"--category", args.category,
|
||||||
|
"--slug", args.slug,
|
||||||
|
"--ext", "html",
|
||||||
|
]
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
cmd.extend(["--file", str(args.file)])
|
||||||
|
else:
|
||||||
|
cmd.extend(["--content", args.content])
|
||||||
|
|
||||||
|
if args.url_only:
|
||||||
|
cmd.append("--url-only")
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
sys.stderr.write(result.stderr)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
print(result.stdout.strip())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add scripts directory to path
|
||||||
|
script_dir = Path(__file__).resolve().parent.parent / "scripts"
|
||||||
|
sys.path.insert(0, str(script_dir))
|
||||||
|
|
||||||
|
from publish_website import validate_html, resolve_hosting_script
|
||||||
|
|
||||||
|
class TestPublishWebsite(unittest.TestCase):
|
||||||
|
def test_validate_html_valid(self):
|
||||||
|
valid_html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<i data-lucide="sparkles"></i>
|
||||||
|
<script>lucide.createIcons();</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
warnings = validate_html(valid_html)
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
def test_validate_html_warnings(self):
|
||||||
|
broken_html = "<html><body>Hello</body></html>"
|
||||||
|
warnings = validate_html(broken_html)
|
||||||
|
self.assertIn("Missing <!DOCTYPE html> declaration.", warnings)
|
||||||
|
self.assertIn("Missing responsive viewport meta tag.", warnings)
|
||||||
|
self.assertIn("Tailwind CSS CDN not detected in HTML.", warnings)
|
||||||
|
self.assertIn("Alpine.js script not detected (interactivity might be missing).", warnings)
|
||||||
|
|
||||||
|
def test_resolve_hosting_script(self):
|
||||||
|
hosting_script = resolve_hosting_script()
|
||||||
|
self.assertTrue(hosting_script.exists())
|
||||||
|
self.assertEqual(hosting_script.name, "publish_file.py")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "datastore-nutrition-logging"
|
||||||
|
description: "Log nutrition intake via datastore CLI; verify kind and repair mis-parsed events."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Datastore Nutrition Logging
|
||||||
|
|
||||||
|
Log consumed food to the datastore CLI and repair events that landed under the wrong kind.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Log an item: run
|
||||||
|
`uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add nutrition.intake --subject <person> --actor agent:koch --payload '{"item": "...", "qty": 250, "unit": "g", "protein_g": 7.5, "carbs_g": 87.5, "fat_g": 27.5, "kcal": 625.0}' --estimated`
|
||||||
|
Pass `nutrition.intake` as the positional argument and `--estimated` as a bare flag with no value. Done when the response contains `feedback.day_total_formatted`.
|
||||||
|
2. Verify the kind: run
|
||||||
|
`... datastore.py query "SELECT id, kind, start, payload FROM events WHERE subject='<person>' AND start >= '<YYYY-MM-DD>'"`.
|
||||||
|
Done when the new event shows `kind = "nutrition.intake"`. If it shows another kind (e.g. `"true"`), a flag value was parsed as the positional kind.
|
||||||
|
3. Repair a mis-kinded event: delete its line from `/home/node/.openclaw/wiki/main/data/events/<wrong-kind>/YYYY-MM.jsonl`, remove the now-empty `<wrong-kind>/` directory, then re-run step 1. Done when step 2 passes.
|
||||||
|
4. Refresh the read-only views: run
|
||||||
|
`PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py`.
|
||||||
|
Done when it prints the rendered nutrition-log paths.
|
||||||
|
5. Report the day total from `feedback.day_total_formatted`; never compute macros by hand.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
name: datastore
|
||||||
|
description: Structured personal data layer for OpenClaw powered by in-memory DuckDB and JSONL in git. Provides deterministic math, macro calculations, baby sleep interval tracking, and spatial radius search. Use whenever logging food, baby sleep, checking daily caloric balances, or querying places.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📦 Datastore: Structured Personal Data Layer
|
||||||
|
|
||||||
|
The `datastore` skill is the source of truth for all structured, repetitive, and mathematical data in OpenClaw. It stores data in git-versioned JSONL files in the Knowledge Wiki (`knowledge-wiki/data/`) and executes all calculations, aggregations, validations, and geospatial queries via in-memory **DuckDB**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 When to Use
|
||||||
|
|
||||||
|
1. **Personal Nutrition (`nutrition.intake`):**
|
||||||
|
- Logging meals, snacks, drinks, and ingredients.
|
||||||
|
- Computes macros and calories deterministically (zero LLM math).
|
||||||
|
- If a `food_id` from the food catalog is provided, macros are multiplied automatically from packaging reference values (`per_100 * qty / 100`).
|
||||||
|
2. **Baby Sleep (`sleep.session`):**
|
||||||
|
- Starting open sleep sessions when Mila falls asleep (`end=None`).
|
||||||
|
- Closing sleep sessions when Mila wakes up (`datastore_stop_event`).
|
||||||
|
- Logging completed nap or night intervals retroactively.
|
||||||
|
- Querying average night durations, wake windows, and daily sleep totals.
|
||||||
|
3. **Daily Caloric Balances:**
|
||||||
|
- Summarizing daily protein, carbs, calories, and deficit against Garmin expenditures.
|
||||||
|
4. **Points of Interest (`places`):**
|
||||||
|
- Searching for beer gardens, playgrounds, stores, or farm stands within a given radius (e.g. 500m or 1km around Grabengasse 7) using native DuckDB spatial distance.
|
||||||
|
5. **Catalogs & Entities (`foods`, `recipes`, `places`, `people`):**
|
||||||
|
- Upserting and reading reference data.
|
||||||
|
6. **Baby Growth & WHO Curves (`growth.measurement`):**
|
||||||
|
- Logging weight (`weight_kg`) and height/length (`height_cm`).
|
||||||
|
- Deterministic WHO child growth standards evaluation (P3, P50 Median, P97) based on exact age in months.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ Core Guardrails & Protocol (MANDATORY)
|
||||||
|
|
||||||
|
- **NEVER compute arithmetic sums by hand:** Always let DuckDB calculate daily totals, durations, and macronutrient sums via `datastore_add_event`, `datastore_stop_event`, or `datastore_get_summary`.
|
||||||
|
- **NEVER write structured log tables directly into Markdown files:** Data goes into the datastore. Markdown view pages in `knowledge-wiki/entities/` are read-only views generated from the datastore.
|
||||||
|
- **Always provide feedback from the datastore response:** When logging an item, echo the DuckDB-calculated running day total (e.g. *"Tag bisher: 85.5 g Eiweiß · 970 kcal"*) provided in the tool response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ MCP Tools
|
||||||
|
|
||||||
|
### 1. `datastore_add_event`
|
||||||
|
Logs a point or interval event.
|
||||||
|
- **Parameters:**
|
||||||
|
- `kind` (string, required): e.g. `'nutrition.intake'`, `'sleep.session'`, `'feeding.bottle'`.
|
||||||
|
- `subject` (string, required): `'moritz'`, `'mila'`, or `'amelie'`.
|
||||||
|
- `actor` (string, required): e.g. `'agent:koch'`, `'agent:hebamme'`, `'moritz'`.
|
||||||
|
- `payload` (object, required):
|
||||||
|
- 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.
|
||||||
|
- `note` (string, optional): Context or notes.
|
||||||
|
|
||||||
|
### 2. `datastore_stop_event`
|
||||||
|
Closes the currently active open interval event (e.g. when Mila wakes up).
|
||||||
|
- **Parameters:**
|
||||||
|
- `kind` (string, required): `'sleep.session'`.
|
||||||
|
- `subject` (string, required): `'mila'`.
|
||||||
|
- `end_time` (string, optional): ISO timestamp (e.g. `'2026-10-11T11:40:00+02:00'`). Defaults to current time if omitted.
|
||||||
|
- `since` (string, optional): Relative wake time offset backwards from now (e.g. `'10m'`, `'15m'`).
|
||||||
|
- `event_id` (string, optional): Specific open event ID to close.
|
||||||
|
|
||||||
|
### 3. `datastore_get_summary`
|
||||||
|
Retrieves daily nutrition, sleep, and active status totals computed by DuckDB.
|
||||||
|
- **Parameters:**
|
||||||
|
- `subject` (string, required): `'moritz'` or `'mila'`.
|
||||||
|
- `date` (string, optional): `YYYY-MM-DD` (defaults to today).
|
||||||
|
|
||||||
|
### 4. `datastore_query`
|
||||||
|
Executes a read-only DuckDB SQL query for custom analysis.
|
||||||
|
- **Parameters:**
|
||||||
|
- `sql` (string, required): e.g. `"SELECT * FROM v_sleep_daily WHERE subject = 'mila' LIMIT 14"`.
|
||||||
|
|
||||||
|
### 5. `datastore_find_places_near`
|
||||||
|
Searches for points of interest within a radius in meters.
|
||||||
|
- **Parameters:**
|
||||||
|
- `lat` (number, required): e.g. `49.0205` (Grabengasse 7).
|
||||||
|
- `lon` (number, required): e.g. `12.0975`.
|
||||||
|
- `radius_m` (number, optional, default: 1000).
|
||||||
|
- `category` (string, optional): e.g. `'biergarten'`, `'spielplatz'`.
|
||||||
|
|
||||||
|
### 6. `datastore_put_entity`
|
||||||
|
Upserts a reference entity into `foods`, `recipes`, `places`, or `people`.
|
||||||
|
- **Parameters:**
|
||||||
|
- `collection` (string, required): `'foods'`, `'recipes'`, `'places'`, or `'people'`.
|
||||||
|
- `record` (object, required): Full entity record with an `'id'` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 CLI Usage
|
||||||
|
|
||||||
|
From the shell or scripts:
|
||||||
|
```bash
|
||||||
|
# Add food
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add nutrition.intake \
|
||||||
|
--subject moritz \
|
||||||
|
--actor agent:koch \
|
||||||
|
--meal Frühstück \
|
||||||
|
--payload '{"item": "Haferflocken mit Skyr", "qty": 250, "unit": "g", "protein_g": 30.0, "kcal": 350.0}'
|
||||||
|
|
||||||
|
# Start Mila's nap (open interval)
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add sleep.session \
|
||||||
|
--subject mila \
|
||||||
|
--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 at current time)
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
|
||||||
|
--subject mila
|
||||||
|
|
||||||
|
# Stop Mila's nap retroactively with specific wake time
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
|
||||||
|
--subject mila \
|
||||||
|
--end "2026-10-11T11:40:00+02:00"
|
||||||
|
|
||||||
|
# Stop Mila's nap retroactively with relative wake time (e.g. woke up 15 min ago)
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
|
||||||
|
--subject mila \
|
||||||
|
--since "15m"
|
||||||
|
|
||||||
|
# Query daily summary
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py summary --subject moritz
|
||||||
|
|
||||||
|
# Radius search for beer gardens within 500m of Grabengasse 7
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py places \
|
||||||
|
--lat 49.0205 --lon 12.0975 --radius 500 --category biergarten
|
||||||
|
|
||||||
|
# Nightly Garmin mirror sync
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2
|
||||||
|
|
||||||
|
# Log Mila's weight and height measurement
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
|
||||||
|
--subject mila \
|
||||||
|
--actor agent:hebamme \
|
||||||
|
--payload '{"weight_kg": 7.4, "height_cm": 67.5}'
|
||||||
|
|
||||||
|
# Retroactively log measurement with date
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
|
||||||
|
--subject mila \
|
||||||
|
--date 2026-06-15 \
|
||||||
|
--payload '{"weight_kg": 5.8, "height_cm": 60.0}'
|
||||||
|
|
||||||
|
# Render Markdown views & HTML dashboards
|
||||||
|
PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts \
|
||||||
|
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py
|
||||||
|
```
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 593 B |
|
After Width: | Height: | Size: 593 B |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 219 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"id": "/family/mila.html",
|
||||||
|
"name": "Mila — Schlaf & Entwicklung",
|
||||||
|
"short_name": "Mila",
|
||||||
|
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
|
||||||
|
"start_url": "/family/mila.html",
|
||||||
|
"scope": "/family/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#090A0F",
|
||||||
|
"theme_color": "#090A0F",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/mila-icon-192-v3.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/mila-icon-192-v3.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/mila-icon-512-v3.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/mila-icon-512-v3.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/mila-apple-touch-icon-v3.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/mila-icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "/family/moritz.html",
|
||||||
|
"name": "Moritz Health",
|
||||||
|
"short_name": "Health",
|
||||||
|
"description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.",
|
||||||
|
"start_url": "/family/moritz.html",
|
||||||
|
"scope": "/family/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f8fafc",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%2310b981'/><path d='M128 256h64l32-96 64 192 32-96h64' fill='none' stroke='white' stroke-width='40' stroke-linecap='round' stroke-linejoin='round'/></svg>",
|
||||||
|
"sizes": "192x192 512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Minimal service worker for Family PWAs (Mila & Moritz) with instant cache-bypassing navigation
|
||||||
|
const CACHE_NAME = 'family-pwa-v3';
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys.map((key) => {
|
||||||
|
if (key !== CACHE_NAME) return caches.delete(key);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
|
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
|
||||||
|
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request, { cache: 'no-cache' }).catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets (images, fonts, scripts): network-first with offline cache fallback
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "boto3>=1.34.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""
|
||||||
|
audit_hosting_boundaries.py — Automated Continuous Leak Prevention Guard (ADR 0005).
|
||||||
|
|
||||||
|
Verifies that no personal, familial, or physiological data files are exposed at the
|
||||||
|
unauthenticated root or public folders of MinIO bucket 'hosting'.
|
||||||
|
Enforces:
|
||||||
|
1. All dashboards for Mila, Moritz, family nutrition, and baby sleep must strictly reside under 'family/'.
|
||||||
|
2. Root and public directories must only contain public assets (/recipes/*, /showcase/*).
|
||||||
|
3. Any violating key found at root or public prefixes is detected and optionally quarantined/purged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
|
||||||
|
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
|
||||||
|
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
|
||||||
|
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
|
||||||
|
|
||||||
|
SENSITIVE_PATTERNS = [
|
||||||
|
"mila",
|
||||||
|
"moritz",
|
||||||
|
"nutrition",
|
||||||
|
"sleep",
|
||||||
|
"schlaf",
|
||||||
|
"health",
|
||||||
|
"diaper",
|
||||||
|
"windel",
|
||||||
|
"fläschchen",
|
||||||
|
"flaeschchen",
|
||||||
|
"garmin",
|
||||||
|
"zwift",
|
||||||
|
"incyclist",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_s3_client():
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=DEFAULT_ENDPOINT,
|
||||||
|
aws_access_key_id=DEFAULT_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=DEFAULT_SECRET_KEY,
|
||||||
|
config=Config(signature_version="s3v4", connect_timeout=5, retries={"max_attempts": 2}),
|
||||||
|
region_name="us-east-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_hosting_bucket(clean: bool = False) -> list[str]:
|
||||||
|
"""Scans MinIO bucket for sensitive personal files outside family/."""
|
||||||
|
s3 = get_s3_client()
|
||||||
|
violations: list[str] = []
|
||||||
|
|
||||||
|
paginator = s3.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(Bucket=DEFAULT_BUCKET):
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
key = obj["Key"]
|
||||||
|
# Whitelisted protected prefixes
|
||||||
|
if key.startswith("family/") or key.startswith("private/"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if key matches sensitive personal data
|
||||||
|
key_lower = key.lower()
|
||||||
|
if any(term in key_lower for term in SENSITIVE_PATTERNS):
|
||||||
|
violations.append(key)
|
||||||
|
|
||||||
|
if violations:
|
||||||
|
print(f"🚨 SECURITY VIOLATION: Found {len(violations)} sensitive personal file(s) outside /family/ in bucket '{DEFAULT_BUCKET}':", file=sys.stderr)
|
||||||
|
for v in violations:
|
||||||
|
print(f" - {v}", file=sys.stderr)
|
||||||
|
if clean:
|
||||||
|
try:
|
||||||
|
s3.delete_object(Bucket=DEFAULT_BUCKET, Key=v)
|
||||||
|
print(f" [CLEANED] Deleted '{v}' from public area.", file=sys.stderr)
|
||||||
|
except Exception as ex:
|
||||||
|
print(f" [ERROR] Failed to delete '{v}': {ex}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"✅ S3 Hosting Audit PASSED: Zero sensitive personal files found in public areas of bucket '{DEFAULT_BUCKET}'.")
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def audit_local_public_vault(vault_dir: Path, clean: bool = False) -> list[Path]:
|
||||||
|
"""Scans local knowledge-wiki/public directory for sensitive files outside family/."""
|
||||||
|
violations: list[Path] = []
|
||||||
|
public_dir = vault_dir / "public"
|
||||||
|
if not public_dir.is_dir():
|
||||||
|
return violations
|
||||||
|
|
||||||
|
for f in public_dir.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
f_lower = f.name.lower()
|
||||||
|
if any(term in f_lower for term in SENSITIVE_PATTERNS):
|
||||||
|
violations.append(f)
|
||||||
|
if clean:
|
||||||
|
try:
|
||||||
|
f.unlink()
|
||||||
|
print(f" [CLEANED] Deleted local unauthenticated file: {f}")
|
||||||
|
except Exception as ex:
|
||||||
|
print(f" [ERROR] Failed to delete {f}: {ex}")
|
||||||
|
|
||||||
|
if violations and not clean:
|
||||||
|
print(f"🚨 Local Vault Public Warning: Found {len(violations)} personal file(s) in {public_dir} root.", file=sys.stderr)
|
||||||
|
elif not violations:
|
||||||
|
print(f"✅ Local Vault Public Audit PASSED: No sensitive files at public root.")
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Audit and enforce ADR 0005 hosting boundary segregation")
|
||||||
|
parser.add_argument("--clean", action="store_true", help="Automatically delete any violating files found")
|
||||||
|
parser.add_argument("--vault", help="Path to knowledge-wiki directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
vault_path = Path(args.vault).resolve() if args.vault else Path(__file__).resolve().parents[4] / "knowledge-wiki"
|
||||||
|
|
||||||
|
s3_violations = []
|
||||||
|
try:
|
||||||
|
s3_violations = audit_hosting_bucket(clean=args.clean)
|
||||||
|
except Exception as ex:
|
||||||
|
print(f"Warning: could not connect to S3/MinIO for audit: {ex}", file=sys.stderr)
|
||||||
|
|
||||||
|
local_violations = audit_local_public_vault(vault_path, clean=args.clean)
|
||||||
|
|
||||||
|
if s3_violations or local_violations:
|
||||||
|
if not args.clean:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "garminconnect>=0.2.14",
|
||||||
|
# "pytz>=2024.1",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
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.data_dir = data_dir
|
||||||
|
self.garmin_dir = data_dir / "garmin"
|
||||||
|
self.daily_dir = self.garmin_dir / "daily"
|
||||||
|
self.sleep_dir = self.garmin_dir / "sleep"
|
||||||
|
self.unified_sleep_dir = data_dir / "sleep"
|
||||||
|
self.activities_dir = self.garmin_dir / "activities"
|
||||||
|
|
||||||
|
for d in [self.daily_dir, self.sleep_dir, self.unified_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
|
||||||
|
dto = sleep_data.get("dailySleepDTO") if isinstance(sleep_data.get("dailySleepDTO"), dict) else {}
|
||||||
|
if dto:
|
||||||
|
cal_date = dto.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)
|
||||||
|
|
||||||
|
# Also upsert into unified data/sleep/<YYYY-MM>.jsonl
|
||||||
|
start_gmt = dto.get("sleepStartTimestampGMT")
|
||||||
|
end_gmt = dto.get("sleepEndTimestampGMT")
|
||||||
|
if start_gmt:
|
||||||
|
start_dt = datetime.datetime.fromtimestamp(start_gmt / 1000, datetime.timezone.utc)
|
||||||
|
start_iso = start_dt.isoformat()
|
||||||
|
else:
|
||||||
|
start_iso = f"{cal_date}T22:30:00+02:00"
|
||||||
|
|
||||||
|
if end_gmt:
|
||||||
|
end_dt = datetime.datetime.fromtimestamp(end_gmt / 1000, datetime.timezone.utc)
|
||||||
|
end_iso = end_dt.isoformat()
|
||||||
|
else:
|
||||||
|
end_iso = f"{cal_date}T06:30:00+02:00"
|
||||||
|
|
||||||
|
nap_sec = dto.get("napTimeSeconds") or 0
|
||||||
|
sleep_sec = dto.get("sleepTimeSeconds") or 0
|
||||||
|
sleep_type = "nap" if (nap_sec > 0 and sleep_sec == 0) else "night"
|
||||||
|
|
||||||
|
unified_rec = {
|
||||||
|
"id": str(dto.get("id") or (int(start_gmt) if start_gmt else cal_date)),
|
||||||
|
"subject": "moritz",
|
||||||
|
"calendarDate": cal_date,
|
||||||
|
"sleepType": sleep_type,
|
||||||
|
"location": "bett",
|
||||||
|
"isOpen": False,
|
||||||
|
"startIso": start_iso,
|
||||||
|
"endIso": end_iso,
|
||||||
|
"sleepStartTimestampGMT": dto.get("sleepStartTimestampGMT"),
|
||||||
|
"sleepEndTimestampGMT": dto.get("sleepEndTimestampGMT"),
|
||||||
|
"sleepStartTimestampLocal": dto.get("sleepStartTimestampLocal"),
|
||||||
|
"sleepEndTimestampLocal": dto.get("sleepEndTimestampLocal"),
|
||||||
|
"sleepTimeSeconds": dto.get("sleepTimeSeconds"),
|
||||||
|
"napTimeSeconds": nap_sec,
|
||||||
|
"deepSleepSeconds": dto.get("deepSleepSeconds"),
|
||||||
|
"lightSleepSeconds": dto.get("lightSleepSeconds"),
|
||||||
|
"remSleepSeconds": dto.get("remSleepSeconds"),
|
||||||
|
"awakeSleepSeconds": dto.get("awakeSleepSeconds"),
|
||||||
|
"awakeCount": dto.get("awakeCount"),
|
||||||
|
"avgSleepStress": dto.get("avgSleepStress"),
|
||||||
|
"avgHeartRate": dto.get("avgHeartRate"),
|
||||||
|
"averageSpO2Value": dto.get("averageSpO2Value"),
|
||||||
|
"lowestSpO2Value": dto.get("lowestSpO2Value"),
|
||||||
|
"highestSpO2Value": dto.get("highestSpO2Value"),
|
||||||
|
"averageRespirationValue": dto.get("averageRespirationValue"),
|
||||||
|
"sleepScores": dto.get("sleepScores"),
|
||||||
|
"avgOvernightHrv": sleep_data.get("avgOvernightHrv"),
|
||||||
|
"hrvStatus": sleep_data.get("hrvStatus"),
|
||||||
|
"bodyBatteryChange": sleep_data.get("bodyBatteryChange"),
|
||||||
|
"restingHeartRate": sleep_data.get("restingHeartRate"),
|
||||||
|
"restlessMomentsCount": sleep_data.get("restlessMomentsCount"),
|
||||||
|
"note": None,
|
||||||
|
"actor": "garmin_sync",
|
||||||
|
"created_at": start_iso,
|
||||||
|
"updated_at": None,
|
||||||
|
"deleted_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
unified_file = self.unified_sleep_dir / f"{month}.jsonl"
|
||||||
|
existing_unified = self._read_existing(unified_file)
|
||||||
|
updated_u = False
|
||||||
|
new_unified = []
|
||||||
|
for item in existing_unified:
|
||||||
|
if item.get("subject") == "moritz" and item.get("calendarDate") == cal_date:
|
||||||
|
new_unified.append(unified_rec)
|
||||||
|
updated_u = True
|
||||||
|
else:
|
||||||
|
new_unified.append(item)
|
||||||
|
|
||||||
|
if not updated_u:
|
||||||
|
new_unified.append(unified_rec)
|
||||||
|
|
||||||
|
new_unified.sort(key=lambda x: (str(x.get("calendarDate", "")), str(x.get("startIso", "")), str(x.get("id", ""))))
|
||||||
|
self._write_atomic(unified_file, new_unified)
|
||||||
|
logger.info(f"Upserted sleep data for {cal_date} into {target_file.name} and {unified_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: Optional[GarminMirrorWriter],
|
||||||
|
dates: List[str],
|
||||||
|
backend_url: Optional[str] = None,
|
||||||
|
):
|
||||||
|
target_backend = None
|
||||||
|
if backend_url is not None:
|
||||||
|
target_backend = backend_url
|
||||||
|
elif os.environ.get("DUCKDB_FORCE_LOCAL") != "1":
|
||||||
|
env_backend = os.environ.get(
|
||||||
|
"DUCKDB_BACKEND_URL", "http://duckdb-backend.family.svc.cluster.local:8000/duckdb-api/v1"
|
||||||
|
).rstrip("/")
|
||||||
|
try:
|
||||||
|
base = env_backend.replace("/duckdb-api/v1", "")
|
||||||
|
test_url = f"{base}/healthz"
|
||||||
|
req = urllib.request.Request(test_url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.0) as r:
|
||||||
|
if r.status == 200:
|
||||||
|
target_backend = env_backend
|
||||||
|
logger.info(f"GarminSync using single-writer backend at {target_backend}")
|
||||||
|
except Exception:
|
||||||
|
target_backend = None
|
||||||
|
|
||||||
|
for d in dates:
|
||||||
|
logger.info(f"--- Syncing Garmin data for {d} ---")
|
||||||
|
summary = None
|
||||||
|
sleep = None
|
||||||
|
activities = None
|
||||||
|
try:
|
||||||
|
summary = client.get_daily_summary(d)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch daily summary for {d}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sleep = client.get_sleep_data(d)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch sleep data for {d}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
activities = client.get_activities(d)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch activities for {d}: {e}")
|
||||||
|
|
||||||
|
synced_via_backend = False
|
||||||
|
if target_backend:
|
||||||
|
try:
|
||||||
|
sync_payload = {
|
||||||
|
"date": d,
|
||||||
|
"daily": [summary] if summary else [],
|
||||||
|
"sleep": [sleep] if sleep else [],
|
||||||
|
"activities": activities or [],
|
||||||
|
"daily_summary": summary,
|
||||||
|
"sleep_data": sleep,
|
||||||
|
}
|
||||||
|
sync_url = f"{target_backend}/garmin/sync"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
sync_url,
|
||||||
|
data=json.dumps(sync_payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
resp_body = {}
|
||||||
|
try:
|
||||||
|
resp_body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
expected_records = (1 if summary else 0) + (1 if sleep else 0) + (len(activities) if activities else 0)
|
||||||
|
synced_records = (
|
||||||
|
resp_body.get("synced_daily", 0)
|
||||||
|
+ resp_body.get("synced_sleep", 0)
|
||||||
|
+ resp_body.get("synced_activities", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status == 200 and (expected_records == 0 or synced_records > 0):
|
||||||
|
logger.info(f"Successfully posted Garmin sync for {d} to backend: {resp_body}")
|
||||||
|
synced_via_backend = True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Backend returned HTTP {resp.status} for {d} but synced {synced_records} records "
|
||||||
|
f"(expected {expected_records}): {resp_body}. Falling back to local writer."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync via backend {target_backend} for {d}: {e}. Falling back to local writer if available.")
|
||||||
|
|
||||||
|
if not synced_via_backend and writer:
|
||||||
|
if summary:
|
||||||
|
writer.upsert_daily_summary(summary)
|
||||||
|
if sleep:
|
||||||
|
writer.upsert_sleep_data(sleep)
|
||||||
|
if activities:
|
||||||
|
writer.upsert_activities(activities)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# dependencies = [
|
||||||
|
# "duckdb>=1.1.0",
|
||||||
|
# "pytz>=2024.1",
|
||||||
|
# "python-ulid>=3.0.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""
|
||||||
|
morning_briefing.py — Generates the daily morning Hebamme sleep roundup for Mila,
|
||||||
|
computes 7d vs 30d trends, renders a visual trend chart via chart-to-chat,
|
||||||
|
and returns the message with MEDIA:<path> and the permanent website URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(SCRIPT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
|
|
||||||
|
from datastore import DatastoreEngine, find_data_dir
|
||||||
|
|
||||||
|
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||||
|
MILA_BIRTHDATE = datetime.date(2026, 3, 14)
|
||||||
|
|
||||||
|
WEEKDAYS_SHORT = {
|
||||||
|
0: "Mo",
|
||||||
|
1: "Di",
|
||||||
|
2: "Mi",
|
||||||
|
3: "Do",
|
||||||
|
4: "Fr",
|
||||||
|
5: "Sa",
|
||||||
|
6: "So",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_chart_script() -> Optional[Path]:
|
||||||
|
candidates = [
|
||||||
|
SCRIPT_DIR.parent.parent / "chart-to-chat" / "scripts" / "chart.py",
|
||||||
|
Path("/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py"),
|
||||||
|
Path("/Users/moritz/src/mop-skills/skills/chart-to-chat/scripts/chart.py"),
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_morning_briefing(force: bool = False) -> Dict[str, Any]:
|
||||||
|
data_dir = find_data_dir()
|
||||||
|
state_file = data_dir / ".mila_morning_state.json"
|
||||||
|
now_berlin = datetime.datetime.now(BERLIN_TZ)
|
||||||
|
today = now_berlin.date()
|
||||||
|
today_str = today.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# State check: only brief once per day unless forced
|
||||||
|
if not force and state_file.is_file():
|
||||||
|
try:
|
||||||
|
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||||
|
if state.get("last_briefed_date") == today_str:
|
||||||
|
return {
|
||||||
|
"already_briefed": True,
|
||||||
|
"date": today_str,
|
||||||
|
"message": None,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
engine = DatastoreEngine(data_dir)
|
||||||
|
con = engine.con
|
||||||
|
|
||||||
|
yesterday = today - datetime.timedelta(days=1)
|
||||||
|
yesterday_str = yesterday.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# 1. Yesterday's sleep
|
||||||
|
y_agg = con.execute(
|
||||||
|
f"""
|
||||||
|
SELECT total_sleep_hours, night_sleep_minutes, nap_count, nap_sleep_minutes
|
||||||
|
FROM v_sleep_daily
|
||||||
|
WHERE subject = 'mila' AND log_date = '{yesterday_str}'
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
y_tot = y_agg[0] if y_agg and y_agg[0] is not None else 0.0
|
||||||
|
y_night_min = y_agg[1] if y_agg and y_agg[1] is not None else 0
|
||||||
|
y_night_h, y_night_m = divmod(y_night_min, 60)
|
||||||
|
y_naps = y_agg[2] if y_agg and y_agg[2] is not None else 0
|
||||||
|
y_naps_min = y_agg[3] if y_agg and y_agg[3] is not None else 0
|
||||||
|
y_naps_h, y_naps_m = divmod(y_naps_min, 60)
|
||||||
|
|
||||||
|
# 2. 7-Day & 30-Day Averages
|
||||||
|
avg_7 = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
|
||||||
|
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
avg_7_tot = avg_7[0] if avg_7 and avg_7[0] is not None else y_tot
|
||||||
|
avg_7_night = avg_7[1] if avg_7 and avg_7[1] is not None else 0.0
|
||||||
|
avg_7_naps = avg_7[2] if avg_7 and avg_7[2] is not None else 0.0
|
||||||
|
|
||||||
|
avg_30 = 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 30)
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
avg_30_tot = avg_30[0] if avg_30 and avg_30[0] is not None else avg_7_tot
|
||||||
|
|
||||||
|
# 3. 7-Day History Data for Chart
|
||||||
|
recent_7_rows = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT log_date, ROUND(total_sleep_hours, 1)
|
||||||
|
FROM v_sleep_daily
|
||||||
|
WHERE subject = 'mila'
|
||||||
|
ORDER BY log_date ASC
|
||||||
|
LIMIT 7
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
chart_media_tag = ""
|
||||||
|
chart_script = find_chart_script()
|
||||||
|
if chart_script and len(recent_7_rows) >= 3:
|
||||||
|
chart_data = []
|
||||||
|
for r in recent_7_rows:
|
||||||
|
d_obj = datetime.date.fromisoformat(str(r[0]))
|
||||||
|
label = f"{WEEKDAYS_SHORT[d_obj.weekday()]} {d_obj.strftime('%d.%m.')}"
|
||||||
|
chart_data.append({"day": label, "hours": float(r[1])})
|
||||||
|
|
||||||
|
try:
|
||||||
|
chart_cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(chart_script),
|
||||||
|
"--title", "Mila Schlaf letzte 7 Tage (Stunden)",
|
||||||
|
"--type", "bar",
|
||||||
|
"--data", json.dumps(chart_data),
|
||||||
|
]
|
||||||
|
res = subprocess.run(chart_cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
for line in res.stdout.splitlines():
|
||||||
|
if line.startswith("MEDIA:"):
|
||||||
|
chart_media_tag = "\n\n" + line.strip()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Mila's exact age & pediatric reflection
|
||||||
|
days_old = (today - MILA_BIRTHDATE).days
|
||||||
|
months_old = days_old // 30.4375
|
||||||
|
|
||||||
|
trend_reflection = (
|
||||||
|
f"Mila schläft im 7-Tage-Schnitt **{avg_7_tot:.1f} h** (30-Tage-Basis: **{avg_30_tot:.1f} h**). "
|
||||||
|
f"Mit rund {int(months_old)} Monaten liegt das wunderbar im altersgerechten Entwicklungsfenster (12–15 Std/24h). "
|
||||||
|
f"Die Hauptschlafphase nachts konsolidiert sich stabil (Ø {avg_7_night:.1f} h), während tagsüber typischerweise 2–3 Schläfchen den Rhythmus tragen."
|
||||||
|
)
|
||||||
|
|
||||||
|
briefing_text = (
|
||||||
|
f"Guten Morgen! ☀️ Hier ist euer morgendlicher Schlafrückblick für Mila:\n\n"
|
||||||
|
f"**Gestern ({yesterday.strftime('%d.%m.')}):**\n"
|
||||||
|
f"• 24h Gesamtschlaf: **{y_tot:.1f} h**\n"
|
||||||
|
f"• Nachtschlaf: **{y_night_h} h {y_night_m:02d} min**\n"
|
||||||
|
f"• Tagesschläfchen: **{y_naps} Naps** ({y_naps_h} h {y_naps_m:02d} min)\n\n"
|
||||||
|
f"**Entwicklung & Trend:**\n"
|
||||||
|
f"• {trend_reflection}\n\n"
|
||||||
|
f"🔗 **Live-Dashboard:** https://family.haumdaucher.de/tracking/mila/\n"
|
||||||
|
f"{chart_media_tag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save state
|
||||||
|
try:
|
||||||
|
state_file.write_text(json.dumps({"last_briefed_date": today_str, "updated_at": now_berlin.isoformat()}), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"already_briefed": False,
|
||||||
|
"date": today_str,
|
||||||
|
"message": briefing_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Mila Morning Sleep Briefing")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Force briefing even if already generated today")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = generate_morning_briefing(force=args.force)
|
||||||
|
if result.get("already_briefed"):
|
||||||
|
print(f"Already briefed for today ({result.get('date')}). Use --force to override.")
|
||||||
|
else:
|
||||||
|
print(result.get("message"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
who_growth.py — WHO Child Growth Standards (Girls 0–24 Months)
|
||||||
|
Deterministic percentile and z-score calculations via LMS method
|
||||||
|
and reference curve data for Mila's growth tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# WHO Child Growth Standards (Girls: Birth to 24 Months)
|
||||||
|
# Source: World Health Organization (WHO) Multi-centre Growth Reference Study (MGRS)
|
||||||
|
# https://www.who.int/tools/child-growth-standards/standards
|
||||||
|
WHO_GIRLS_WEIGHT = [
|
||||||
|
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
|
||||||
|
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
|
||||||
|
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
|
||||||
|
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
|
||||||
|
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
|
||||||
|
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
|
||||||
|
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
|
||||||
|
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
|
||||||
|
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
|
||||||
|
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
|
||||||
|
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
|
||||||
|
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
|
||||||
|
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
|
||||||
|
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
|
||||||
|
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
|
||||||
|
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
|
||||||
|
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
|
||||||
|
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
|
||||||
|
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
|
||||||
|
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
|
||||||
|
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
|
||||||
|
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
|
||||||
|
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
|
||||||
|
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
|
||||||
|
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
|
||||||
|
]
|
||||||
|
|
||||||
|
WHO_GIRLS_HEIGHT = [
|
||||||
|
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
|
||||||
|
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
|
||||||
|
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
|
||||||
|
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
|
||||||
|
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
|
||||||
|
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
|
||||||
|
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
|
||||||
|
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
|
||||||
|
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
|
||||||
|
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
|
||||||
|
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
|
||||||
|
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
|
||||||
|
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
|
||||||
|
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
|
||||||
|
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
|
||||||
|
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
|
||||||
|
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
|
||||||
|
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
|
||||||
|
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
|
||||||
|
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
|
||||||
|
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
|
||||||
|
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
|
||||||
|
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
|
||||||
|
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
|
||||||
|
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
|
||||||
|
"""Linearly interpolates L, M, S, p3, p50, p97 for a given age in months."""
|
||||||
|
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
|
||||||
|
m_low = int(math.floor(m_clamped))
|
||||||
|
m_high = int(math.ceil(m_clamped))
|
||||||
|
|
||||||
|
r_low = table[m_low]
|
||||||
|
if m_low == m_high:
|
||||||
|
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
|
||||||
|
|
||||||
|
r_high = table[m_high]
|
||||||
|
frac = m_clamped - m_low
|
||||||
|
|
||||||
|
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
|
||||||
|
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
|
||||||
|
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
|
||||||
|
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
|
||||||
|
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
|
||||||
|
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
|
||||||
|
|
||||||
|
return L, M, S, p3, p50, p97
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Computes exact WHO z-score and percentile using the LMS formula.
|
||||||
|
metric: 'weight' (in kg) or 'height' (in cm)
|
||||||
|
"""
|
||||||
|
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
|
||||||
|
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
|
||||||
|
|
||||||
|
if value <= 0 or M <= 0 or S <= 0:
|
||||||
|
return {
|
||||||
|
"value": value,
|
||||||
|
"age_months": round(age_months, 2),
|
||||||
|
"z_score": 0.0,
|
||||||
|
"percentile": 50.0,
|
||||||
|
"percentile_str": "P50",
|
||||||
|
"p3": round(p3, 2),
|
||||||
|
"p50": round(p50, 2),
|
||||||
|
"p97": round(p97, 2),
|
||||||
|
"in_normal_range": True,
|
||||||
|
"status": "normal",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Box-Cox power exponential LMS formula
|
||||||
|
if abs(L) > 1e-6:
|
||||||
|
z = ((value / M) ** L - 1.0) / (L * S)
|
||||||
|
else:
|
||||||
|
z = math.log(value / M) / S
|
||||||
|
|
||||||
|
# Cumulative standard normal distribution
|
||||||
|
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
|
||||||
|
p_clamped = max(0.1, min(99.9, p))
|
||||||
|
|
||||||
|
in_normal = 3.0 <= p_clamped <= 97.0
|
||||||
|
if p_clamped < 3.0:
|
||||||
|
status = "unter P3"
|
||||||
|
elif p_clamped > 97.0:
|
||||||
|
status = "über P97"
|
||||||
|
else:
|
||||||
|
status = "im WHO-Normbereich"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"value": value,
|
||||||
|
"age_months": round(age_months, 2),
|
||||||
|
"z_score": round(z, 2),
|
||||||
|
"percentile": round(p_clamped, 1),
|
||||||
|
"percentile_str": f"P{int(round(p_clamped))}",
|
||||||
|
"p3": round(p3, 2),
|
||||||
|
"p50": round(p50, 2),
|
||||||
|
"p97": round(p97, 2),
|
||||||
|
"in_normal_range": in_normal,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_growth_feedback(
|
||||||
|
weight_kg: Optional[float],
|
||||||
|
height_cm: Optional[float],
|
||||||
|
age_months: float,
|
||||||
|
date_str: str,
|
||||||
|
) -> str:
|
||||||
|
"""Formats clear, comforting feedback for CLI and Hebamme agent responses."""
|
||||||
|
parts = []
|
||||||
|
if weight_kg is not None:
|
||||||
|
pw = calculate_percentile("weight", weight_kg, age_months)
|
||||||
|
parts.append(f"{weight_kg:.2f} kg ({pw['percentile_str']} · WHO Median: {pw['p50']:.1f} kg)")
|
||||||
|
|
||||||
|
if height_cm is not None:
|
||||||
|
ph = calculate_percentile("height", height_cm, age_months)
|
||||||
|
parts.append(f"{height_cm:.1f} cm ({ph['percentile_str']} · WHO Median: {ph['p50']:.1f} cm)")
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return f"📏 Messung ({date_str}) ohne Werte geloggt."
|
||||||
|
|
||||||
|
details = " · ".join(parts)
|
||||||
|
return f"📏 Wachstum ({date_str}, ~{age_months:.1f} Mon): {details} [Normbereich: P3–P97]"
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from datastore import DatastoreEngine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_datastore():
|
||||||
|
"""Creates a temporary datastore directory with schemas for testing."""
|
||||||
|
temp_dir = Path(tempfile.mkdtemp())
|
||||||
|
data_dir = temp_dir / "data"
|
||||||
|
data_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Copy schemas from knowledge-wiki/data/_schema if available, or create them
|
||||||
|
schema_src = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki/data/_schema")
|
||||||
|
if schema_src.is_dir():
|
||||||
|
shutil.copytree(schema_src, data_dir / "_schema")
|
||||||
|
|
||||||
|
(data_dir / "_meta.json").write_text("{}")
|
||||||
|
engine = DatastoreEngine(data_dir=data_dir)
|
||||||
|
yield engine
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nutrition_explicit_logging(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies logging food with explicit macro values and daily aggregation."""
|
||||||
|
res1 = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-06T08:30:00+02:00",
|
||||||
|
meal="Frühstück",
|
||||||
|
payload={
|
||||||
|
"item": "Protein Porridge",
|
||||||
|
"qty": 300,
|
||||||
|
"unit": "g",
|
||||||
|
"protein_g": 35.5,
|
||||||
|
"carbs_g": 45.0,
|
||||||
|
"sugar_g": 5.0,
|
||||||
|
"fat_g": 8.0,
|
||||||
|
"fiber_g": 6.0,
|
||||||
|
"salt_g": 0.5,
|
||||||
|
"kcal": 420.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res1["status"] == "success"
|
||||||
|
assert res1["event"]["id"] is not None
|
||||||
|
|
||||||
|
res2 = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-06T12:30:00+02:00",
|
||||||
|
meal="Mittagessen",
|
||||||
|
payload={
|
||||||
|
"item": "Hähnchen mit Reis",
|
||||||
|
"qty": 400,
|
||||||
|
"unit": "g",
|
||||||
|
"protein_g": 50.0,
|
||||||
|
"carbs_g": 60.0,
|
||||||
|
"sugar_g": 2.0,
|
||||||
|
"fat_g": 10.0,
|
||||||
|
"fiber_g": 4.0,
|
||||||
|
"salt_g": 1.2,
|
||||||
|
"kcal": 550.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res2["status"] == "success"
|
||||||
|
|
||||||
|
summary = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
||||||
|
nut = summary["nutrition"]
|
||||||
|
assert nut["item_count"] == 2
|
||||||
|
assert nut["total_protein_g"] == 85.5
|
||||||
|
assert nut["total_carbs_g"] == 105.0
|
||||||
|
assert nut["total_kcal"] == 970.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_nutrition_from_food_reference(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies that linking a food_id computes macros automatically from foods.per_100."""
|
||||||
|
temp_datastore.put_entity(
|
||||||
|
collection="foods",
|
||||||
|
record={
|
||||||
|
"id": "oatly-haferdrink",
|
||||||
|
"name": "Oatly Haferdrink",
|
||||||
|
"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.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log 200 ml Oatly Haferdrink
|
||||||
|
res = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-06T09:00:00+02:00",
|
||||||
|
payload={
|
||||||
|
"item": "Hafermilch im Kaffee",
|
||||||
|
"food_id": "oatly-haferdrink",
|
||||||
|
"qty": 200,
|
||||||
|
"unit": "ml",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res["status"] == "success"
|
||||||
|
|
||||||
|
# Query v_nutrition
|
||||||
|
rows = temp_datastore.query("SELECT protein_g, carbs_g, kcal FROM v_nutrition WHERE food_id = 'oatly-haferdrink'")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert round(rows[0]["protein_g"], 2) == 2.2
|
||||||
|
assert round(rows[0]["carbs_g"], 2) == 14.2
|
||||||
|
assert round(rows[0]["kcal"], 0) == 122.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_baby_sleep_open_and_close(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies open-ended interval sleep logging and stop_event duration calculations."""
|
||||||
|
start_time = "2026-10-06T13:00:00+02:00"
|
||||||
|
end_time = "2026-10-06T14:30:00+02:00"
|
||||||
|
|
||||||
|
# 1. Start open sleep
|
||||||
|
res_start = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start=start_time,
|
||||||
|
payload={"type": "nap", "location": "bett"},
|
||||||
|
)
|
||||||
|
assert res_start["status"] == "success"
|
||||||
|
assert res_start["event"]["end"] is None
|
||||||
|
|
||||||
|
# Verify open status
|
||||||
|
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
|
||||||
|
assert "open_sleep" in summary
|
||||||
|
assert summary["open_sleep"]["sleep_type"] == "nap"
|
||||||
|
|
||||||
|
# 2. Prevent overlapping second open sleep
|
||||||
|
with pytest.raises(ValueError, match="hat bereits ein aktives Schlaf-Event"):
|
||||||
|
temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-06T13:30:00+02:00",
|
||||||
|
payload={"type": "nap"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Stop sleep
|
||||||
|
res_stop = temp_datastore.stop_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
assert res_stop["status"] == "success"
|
||||||
|
assert res_stop["duration_minutes"] == 90
|
||||||
|
assert res_stop["duration_formatted"] == "1 h 30 min"
|
||||||
|
|
||||||
|
# 4. Verify aggregated summary
|
||||||
|
summary2 = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
|
||||||
|
slp = summary2["sleep"]
|
||||||
|
assert slp["nap_count"] == 1
|
||||||
|
assert slp["nap_sleep_minutes"] == 90
|
||||||
|
assert slp["total_sleep_hours"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_delete_event(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies that soft-deleted events are excluded from views."""
|
||||||
|
res = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-06T07:00:00+02:00",
|
||||||
|
payload={"item": "Snack", "qty": 50, "unit": "g", "protein_g": 5.0, "kcal": 100.0},
|
||||||
|
)
|
||||||
|
event_id = res["event"]["id"]
|
||||||
|
|
||||||
|
# Verify present in summary
|
||||||
|
sum1 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
||||||
|
assert sum1["nutrition"]["total_kcal"] == 100.0
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
del_res = temp_datastore.delete_event(event_id)
|
||||||
|
assert del_res["status"] == "success"
|
||||||
|
|
||||||
|
# Verify removed from view
|
||||||
|
sum2 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
|
||||||
|
assert sum2["nutrition"]["total_kcal"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_places_spatial_radius_search_both_engines(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies distance calculation using both DuckDB spatial extension and pure-SQL fallback macro."""
|
||||||
|
temp_datastore.put_entity(
|
||||||
|
collection="places",
|
||||||
|
record={
|
||||||
|
"id": "spitalgarten-regensburg",
|
||||||
|
"name": "Spitalgarten Regensburg",
|
||||||
|
"category": "biergarten",
|
||||||
|
"lat": 49.0223,
|
||||||
|
"lon": 12.0978,
|
||||||
|
"address": "St.-Katharinen-Platz 1, 93059 Regensburg",
|
||||||
|
"google_maps_url": "https://maps.app.goo.gl/spitalgarten",
|
||||||
|
"tags": ["biergarten", "spielplatz"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
temp_datastore.put_entity(
|
||||||
|
collection="places",
|
||||||
|
record={
|
||||||
|
"id": "walhalla-donaustauf",
|
||||||
|
"name": "Walhalla",
|
||||||
|
"category": "sightseeing",
|
||||||
|
"lat": 49.0315,
|
||||||
|
"lon": 12.2245,
|
||||||
|
"address": "Walhallastraße 48, 93093 Donaustauf",
|
||||||
|
"google_maps_url": "https://maps.app.goo.gl/walhalla",
|
||||||
|
"tags": ["ausflug", "kultur"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Test with spatial extension enabled
|
||||||
|
places_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
|
||||||
|
assert len(places_1k) == 1
|
||||||
|
assert places_1k[0]["id"] == "spitalgarten-regensburg"
|
||||||
|
assert 150.0 < places_1k[0]["distance_meters"] < 300.0
|
||||||
|
|
||||||
|
# 2. Test fallback macro by forcing has_spatial_extension = False
|
||||||
|
temp_datastore.has_spatial_extension = False
|
||||||
|
places_fallback_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
|
||||||
|
assert len(places_fallback_1k) == 1
|
||||||
|
assert places_fallback_1k[0]["id"] == "spitalgarten-regensburg"
|
||||||
|
assert 150.0 < places_fallback_1k[0]["distance_meters"] < 300.0
|
||||||
|
# Differences between Spheroid (WGS84 ellipsoid) and Haversine (sphere) are < 3m
|
||||||
|
assert abs(places_1k[0]["distance_meters"] - places_fallback_1k[0]["distance_meters"]) < 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_garmin_daily_and_daily_balance(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies that Garmin daily summaries join with nutrition to calculate caloric deficit/surplus deterministically."""
|
||||||
|
# 1. Log nutrition intake on 2026-10-01
|
||||||
|
temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-01T12:30:00+02:00",
|
||||||
|
payload={"item": "Mittagessen", "qty": 400, "unit": "g", "protein_g": 60.0, "kcal": 800.0},
|
||||||
|
)
|
||||||
|
temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-01T19:30:00+02:00",
|
||||||
|
payload={"item": "Abendessen", "qty": 500, "unit": "g", "protein_g": 80.0, "kcal": 1346.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Add Garmin daily summary for 2026-10-01
|
||||||
|
garmin_dir = temp_datastore.data_dir / "garmin" / "daily"
|
||||||
|
garmin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
garmin_file = garmin_dir / "2026-10.jsonl"
|
||||||
|
with open(garmin_file, "a", encoding="utf-8") as f:
|
||||||
|
f.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"calendarDate": "2026-10-01",
|
||||||
|
"totalKilocalories": 2890,
|
||||||
|
"activeKilocalories": 650,
|
||||||
|
"bmrKilocalories": 2240,
|
||||||
|
"totalSteps": 10500,
|
||||||
|
"restingHeartRate": 51,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Reload data into datastore
|
||||||
|
temp_datastore._load_all_data()
|
||||||
|
|
||||||
|
# 4. Query v_daily_balance
|
||||||
|
res = temp_datastore.query("SELECT * FROM v_daily_balance WHERE log_date = '2026-10-01'")
|
||||||
|
assert len(res) == 1
|
||||||
|
row = res[0]
|
||||||
|
assert row["subject"] == "moritz"
|
||||||
|
assert row["intake_kcal"] == 2146.0 # 800 + 1346
|
||||||
|
assert row["protein_g"] == 140.0 # 60 + 80
|
||||||
|
assert row["expenditure_kcal"] == 2890
|
||||||
|
assert row["active_kcal"] == 650
|
||||||
|
assert row["rmr_kcal"] == 2240
|
||||||
|
assert row["steps"] == 10500
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_suppression_nutrition(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies that duplicate intake events within 15 minutes are suppressed idempotently."""
|
||||||
|
start_time = "2026-10-09T08:00:00+02:00"
|
||||||
|
payload = {
|
||||||
|
"item": "Protein Porridge",
|
||||||
|
"qty": 300,
|
||||||
|
"unit": "g",
|
||||||
|
"protein_g": 35.0,
|
||||||
|
"kcal": 400.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. First logging
|
||||||
|
res1 = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start=start_time,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
assert res1["status"] == "success"
|
||||||
|
assert res1.get("duplicate") is not True
|
||||||
|
first_id = res1["event"]["id"]
|
||||||
|
|
||||||
|
# 2. Duplicate logging 5 minutes later
|
||||||
|
res2 = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-09T08:05:00+02:00",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
assert res2["status"] == "success"
|
||||||
|
assert res2.get("duplicate") is True
|
||||||
|
assert res2["event"]["id"] == first_id
|
||||||
|
|
||||||
|
# Verify DuckDB summary: only 1 item counted
|
||||||
|
sum1 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-09")
|
||||||
|
assert sum1["nutrition"]["item_count"] == 1
|
||||||
|
assert sum1["nutrition"]["total_kcal"] == 400.0
|
||||||
|
|
||||||
|
# 3. Explicit bypass with allow_duplicate=True
|
||||||
|
res3 = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-09T08:06:00+02:00",
|
||||||
|
payload=payload,
|
||||||
|
allow_duplicate=True,
|
||||||
|
)
|
||||||
|
assert res3["status"] == "success"
|
||||||
|
assert res3.get("duplicate") is not True
|
||||||
|
assert res3["event"]["id"] != first_id
|
||||||
|
|
||||||
|
# Verify DuckDB summary: 2 items counted
|
||||||
|
sum2 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-09")
|
||||||
|
assert sum2["nutrition"]["item_count"] == 2
|
||||||
|
assert sum2["nutrition"]["total_kcal"] == 800.0
|
||||||
|
|
||||||
|
# 4. Different item within 15 minutes is NOT suppressed
|
||||||
|
res4 = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-09T08:07:00+02:00",
|
||||||
|
payload={"item": "Apfel", "qty": 150, "kcal": 80.0},
|
||||||
|
)
|
||||||
|
assert res4.get("duplicate") is not True
|
||||||
|
|
||||||
|
# 5. Same item outside 15 minutes window is NOT suppressed
|
||||||
|
res5 = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:koch",
|
||||||
|
start="2026-10-09T09:30:00+02:00",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
assert res5.get("duplicate") is not True
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_suppression_sleep(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies that starting the same sleep session within 15 minutes returns existing open session."""
|
||||||
|
res1 = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-09T13:00:00+02:00",
|
||||||
|
payload={"type": "nap", "location": "bett"},
|
||||||
|
)
|
||||||
|
assert res1["status"] == "success"
|
||||||
|
first_id = res1["event"]["id"]
|
||||||
|
|
||||||
|
# Repeat 3 minutes later: should return existing open session without throwing ValueError
|
||||||
|
res2 = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-09T13:03:00+02:00",
|
||||||
|
payload={"type": "nap", "location": "bett"},
|
||||||
|
)
|
||||||
|
assert res2["status"] == "success"
|
||||||
|
assert res2.get("duplicate") is True
|
||||||
|
assert res2["event"]["id"] == first_id
|
||||||
|
|
||||||
|
# Stop session
|
||||||
|
temp_datastore.stop_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
end_time="2026-10-09T14:00:00+02:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_kind_flag_and_positional(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies CLI accepts both positional and --kind flag for add and stop commands."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
script_path = Path(__file__).resolve().parent.parent / "scripts" / "datastore.py"
|
||||||
|
data_dir = str(temp_datastore.data_dir)
|
||||||
|
|
||||||
|
# 1. Add using --kind flag
|
||||||
|
proc_add = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(script_path),
|
||||||
|
"add",
|
||||||
|
"--kind",
|
||||||
|
"nutrition.intake",
|
||||||
|
"--subject",
|
||||||
|
"moritz",
|
||||||
|
"--payload",
|
||||||
|
'{"item": "CLI Snack", "qty": 50, "kcal": 100}',
|
||||||
|
"--start",
|
||||||
|
"2026-10-09T15:00:00+02:00",
|
||||||
|
],
|
||||||
|
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert proc_add.returncode == 0
|
||||||
|
data = json.loads(proc_add.stdout)
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["event"]["kind"] == "nutrition.intake"
|
||||||
|
|
||||||
|
# 2. Add open sleep using positional kind
|
||||||
|
proc_sleep_start = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(script_path),
|
||||||
|
"add",
|
||||||
|
"sleep.session",
|
||||||
|
"--subject",
|
||||||
|
"mila",
|
||||||
|
"--payload",
|
||||||
|
'{"type": "nap"}',
|
||||||
|
"--start",
|
||||||
|
"2026-10-09T15:00:00+02:00",
|
||||||
|
],
|
||||||
|
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert proc_sleep_start.returncode == 0
|
||||||
|
|
||||||
|
# 3. Stop sleep using --kind flag
|
||||||
|
proc_sleep_stop = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(script_path),
|
||||||
|
"stop",
|
||||||
|
"--kind",
|
||||||
|
"sleep.session",
|
||||||
|
"--subject",
|
||||||
|
"mila",
|
||||||
|
"--end",
|
||||||
|
"2026-10-09T16:00:00+02:00",
|
||||||
|
],
|
||||||
|
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert proc_sleep_stop.returncode == 0
|
||||||
|
stop_data = json.loads(proc_sleep_stop.stdout)
|
||||||
|
assert stop_data["status"] == "success"
|
||||||
|
assert stop_data["duration_minutes"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_session_warning_without_silent_deckeln(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies that an unclosed sleep session is NOT silently deckelt, but alerts on every new log."""
|
||||||
|
# 1. Open night sleep session yesterday at 19:15
|
||||||
|
res_night = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-08T19:15:00+02:00",
|
||||||
|
payload={"type": "night", "location": "bett"},
|
||||||
|
)
|
||||||
|
assert res_night["status"] == "success"
|
||||||
|
night_id = res_night["event"]["id"]
|
||||||
|
|
||||||
|
# 2. Add midday nap today (12:35 to 12:55)
|
||||||
|
res_nap = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-09T12:35:00+02:00",
|
||||||
|
end="2026-10-09T12:55:00+02:00",
|
||||||
|
payload={"type": "nap", "location": "bett"},
|
||||||
|
)
|
||||||
|
assert res_nap["status"] == "success"
|
||||||
|
|
||||||
|
# 3. Verify night session is STILL open (never silently deckelt!)
|
||||||
|
rows = temp_datastore.query(f"SELECT id, start, \"end\", estimated FROM events WHERE id = '{night_id}'")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["end"] is None
|
||||||
|
assert rows[0]["estimated"] is False
|
||||||
|
|
||||||
|
# 4. Verify that feedback gives prominent notice about the open session
|
||||||
|
feedback = res_nap["feedback"]
|
||||||
|
assert "open_session_warning" in feedback
|
||||||
|
assert "ist noch offen" in feedback["open_session_warning"]
|
||||||
|
assert "ist noch offen" in feedback["day_total_formatted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_event_by_id(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies closing a specific open sleep session by ID when multiple might exist."""
|
||||||
|
res1 = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-08T19:15:00+02:00",
|
||||||
|
payload={"type": "night"},
|
||||||
|
)
|
||||||
|
id1 = res1["event"]["id"]
|
||||||
|
|
||||||
|
res2 = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-09T12:35:00+02:00",
|
||||||
|
payload={"type": "nap"},
|
||||||
|
)
|
||||||
|
id2 = res2["event"]["id"]
|
||||||
|
|
||||||
|
# Close specific event id1
|
||||||
|
res_stop = temp_datastore.stop_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
event_id=id1,
|
||||||
|
end_time="2026-10-09T07:15:00+02:00",
|
||||||
|
)
|
||||||
|
assert res_stop["status"] == "success"
|
||||||
|
assert res_stop["event_id"] == id1
|
||||||
|
|
||||||
|
# Verify id1 is closed and id2 is still open
|
||||||
|
row1 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id1}'")[0]
|
||||||
|
row2 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id2}'")[0]
|
||||||
|
assert row1["end"] is not None
|
||||||
|
assert row2["end"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies CLI rejects boolean words or strings without dots as kind."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
script_path = Path(__file__).resolve().parent.parent / "scripts" / "datastore.py"
|
||||||
|
data_dir = str(temp_datastore.data_dir)
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(script_path),
|
||||||
|
"add",
|
||||||
|
"true",
|
||||||
|
"--subject",
|
||||||
|
"moritz",
|
||||||
|
"--payload",
|
||||||
|
'{"item": "Snack", "qty": 100}',
|
||||||
|
],
|
||||||
|
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert proc.returncode != 0
|
||||||
|
assert "Invalid kind 'true'" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_diaper_logging(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies diaper change events, feedback, and DuckDB views."""
|
||||||
|
res = temp_datastore.add_event(
|
||||||
|
kind="diaper.change",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-11T09:45:00+02:00",
|
||||||
|
payload={"pee": True, "poo": True, "texture": "normal"},
|
||||||
|
)
|
||||||
|
assert res["status"] == "success"
|
||||||
|
feedback = res["feedback"]
|
||||||
|
assert feedback["total_changes"] == 1
|
||||||
|
assert feedback["wet_count"] == 1
|
||||||
|
assert feedback["dirty_count"] == 1
|
||||||
|
assert "1 gewechselt" in feedback["day_total_formatted"]
|
||||||
|
|
||||||
|
# Add second diaper (only pee)
|
||||||
|
res2 = temp_datastore.add_event(
|
||||||
|
kind="diaper.change",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-11T12:30:00+02:00",
|
||||||
|
payload={"pee": True, "poo": False},
|
||||||
|
)
|
||||||
|
assert res2["status"] == "success"
|
||||||
|
feedback2 = res2["feedback"]
|
||||||
|
assert feedback2["total_changes"] == 2
|
||||||
|
assert feedback2["wet_count"] == 2
|
||||||
|
assert feedback2["dirty_count"] == 1
|
||||||
|
|
||||||
|
# Check v_diaper and v_diaper_daily views
|
||||||
|
rows = temp_datastore.query("SELECT * FROM v_diaper WHERE subject = 'mila'")
|
||||||
|
assert len(rows) == 2
|
||||||
|
daily = temp_datastore.query("SELECT * FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-11'")
|
||||||
|
assert len(daily) == 1
|
||||||
|
assert daily[0]["total_changes"] == 2
|
||||||
|
assert daily[0]["wet_count"] == 2
|
||||||
|
assert daily[0]["dirty_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_feeding_logging(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies feeding bottle events, feedback, and DuckDB views."""
|
||||||
|
res = temp_datastore.add_event(
|
||||||
|
kind="feeding.bottle",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-11T06:30:00+02:00",
|
||||||
|
payload={"amount_ml": 180, "consumed_ml": 160, "milk_type": "pre"},
|
||||||
|
)
|
||||||
|
assert res["status"] == "success"
|
||||||
|
feedback = res["feedback"]
|
||||||
|
assert feedback["bottle_count"] == 1
|
||||||
|
assert feedback["total_consumed_ml"] == 160.0
|
||||||
|
assert "160 ml getrunken" in feedback["day_total_formatted"]
|
||||||
|
|
||||||
|
# Check v_feeding_daily view
|
||||||
|
daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-11'")
|
||||||
|
assert len(daily) == 1
|
||||||
|
assert daily[0]["bottle_count"] == 1
|
||||||
|
assert daily[0]["total_consumed_ml"] == 160.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_growth_logging(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies baby growth measurements, WHO percentile feedback, and DuckDB views."""
|
||||||
|
# Ensure Mila is in people table with birthdate
|
||||||
|
temp_datastore.put_entity(
|
||||||
|
collection="people",
|
||||||
|
record={"id": "mila", "name": "Mila Graf", "birthdate": "2026-03-14", "role": "child"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log measurement at approx 6.9 months (2026-10-12)
|
||||||
|
res = temp_datastore.add_event(
|
||||||
|
kind="growth.measurement",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:hebamme",
|
||||||
|
start="2026-10-12T10:00:00+02:00",
|
||||||
|
payload={"weight_kg": 7.4, "height_cm": 67.5},
|
||||||
|
note="U-Untersuchung Vorbereitung",
|
||||||
|
)
|
||||||
|
assert res["status"] == "success"
|
||||||
|
fb = res["feedback"]
|
||||||
|
assert "weight_kg" in fb
|
||||||
|
assert fb["weight_kg"] == 7.4
|
||||||
|
assert fb["height_cm"] == 67.5
|
||||||
|
assert "weight_percentile" in fb
|
||||||
|
assert 30 <= fb["weight_percentile"] <= 60 # around P41
|
||||||
|
assert 45 <= fb["height_percentile"] <= 70 # around P56
|
||||||
|
assert "Normbereich" in fb["day_total_formatted"]
|
||||||
|
|
||||||
|
# Verify DuckDB views
|
||||||
|
growth_rows = temp_datastore.query("SELECT * FROM v_growth WHERE subject = 'mila'")
|
||||||
|
assert len(growth_rows) == 1
|
||||||
|
assert growth_rows[0]["weight_kg"] == 7.4
|
||||||
|
assert growth_rows[0]["height_cm"] == 67.5
|
||||||
|
assert growth_rows[0]["note"] == "U-Untersuchung Vorbereitung"
|
||||||
|
|
||||||
|
daily_rows = temp_datastore.query("SELECT * FROM v_growth_daily WHERE subject = 'mila'")
|
||||||
|
assert len(daily_rows) == 1
|
||||||
|
assert daily_rows[0]["measurement_count"] == 1
|
||||||
|
|
||||||
|
# Verify get_summary returns growth_latest
|
||||||
|
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-12")
|
||||||
|
assert "growth_latest" in summary
|
||||||
|
assert summary["growth_latest"]["weight_kg"] == 7.4
|
||||||
|
assert summary["growth_latest"]["height_cm"] == 67.5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_feeding_solid_brei_logging(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies feeding solid (Brei/Beikost) events, feedback formatting, and DuckDB views."""
|
||||||
|
res = temp_datastore.add_event(
|
||||||
|
kind="feeding.solid",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:mila_tracker",
|
||||||
|
start="2026-10-12T12:00:00+02:00",
|
||||||
|
meal="Mittagessen",
|
||||||
|
payload={
|
||||||
|
"item": "Kürbis-Kartoffel-Brei",
|
||||||
|
"amount_g": 130,
|
||||||
|
"kcal": 95,
|
||||||
|
},
|
||||||
|
note="Guter Appetit",
|
||||||
|
)
|
||||||
|
assert res["status"] == "success"
|
||||||
|
fb = res["feedback"]
|
||||||
|
assert fb["solid_count"] == 1
|
||||||
|
assert fb["total_solid_g"] == 130.0
|
||||||
|
assert "130 g" in fb["day_total_formatted"]
|
||||||
|
assert "Brei" in fb["day_total_formatted"]
|
||||||
|
assert res["event"]["payload"]["item"] == "Kürbis-Kartoffel-Brei"
|
||||||
|
|
||||||
|
# Verify v_feeding view
|
||||||
|
rows = temp_datastore.query("SELECT * FROM v_feeding WHERE subject = 'mila' AND kind = 'feeding.solid'")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["item"] == "Kürbis-Kartoffel-Brei"
|
||||||
|
assert rows[0]["solid_g"] == 130.0
|
||||||
|
assert rows[0]["kcal"] == 95.0
|
||||||
|
|
||||||
|
# Verify v_feeding_daily combined aggregation
|
||||||
|
daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-12'")
|
||||||
|
assert len(daily) == 1
|
||||||
|
assert daily[0]["solid_count"] == 1
|
||||||
|
assert daily[0]["total_solid_g"] == 130.0
|
||||||
|
assert daily[0]["total_kcal"] == 95.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_databases_isolation_and_reloading(temp_datastore: DatastoreEngine):
|
||||||
|
"""Verifies that events are routed to sleep/, nutrition/, mila/ databases and reloaded correctly."""
|
||||||
|
# 1. Sleep session -> sleep/
|
||||||
|
res_sleep = temp_datastore.add_event(
|
||||||
|
kind="sleep.session",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:mila_tracker",
|
||||||
|
start="2026-10-13T09:00:00+02:00",
|
||||||
|
end="2026-10-13T10:00:00+02:00",
|
||||||
|
payload={"type": "nap", "location": "bett"},
|
||||||
|
)
|
||||||
|
assert res_sleep["status"] == "success"
|
||||||
|
|
||||||
|
# 2. Nutrition intake -> nutrition/
|
||||||
|
res_nut = temp_datastore.add_event(
|
||||||
|
kind="nutrition.intake",
|
||||||
|
subject="moritz",
|
||||||
|
actor="agent:nutrition_tracker",
|
||||||
|
start="2026-10-13T08:00:00+02:00",
|
||||||
|
meal="Frühstück",
|
||||||
|
payload={"item": "Rührei", "qty": 150, "unit": "g", "protein_g": 18.0, "kcal": 210.0},
|
||||||
|
)
|
||||||
|
assert res_nut["status"] == "success"
|
||||||
|
|
||||||
|
# 3. Diaper change -> mila/
|
||||||
|
res_diaper = temp_datastore.add_event(
|
||||||
|
kind="diaper.change",
|
||||||
|
subject="mila",
|
||||||
|
actor="agent:mila_tracker",
|
||||||
|
start="2026-10-13T10:15:00+02:00",
|
||||||
|
payload={"pee": True, "poo": False},
|
||||||
|
)
|
||||||
|
assert res_diaper["status"] == "success"
|
||||||
|
|
||||||
|
# Verify file locations on disk
|
||||||
|
data_dir = temp_datastore.data_dir
|
||||||
|
sleep_files = list((data_dir / "sleep").glob("*.jsonl"))
|
||||||
|
nutrition_files = list((data_dir / "nutrition").glob("*.jsonl"))
|
||||||
|
mila_files = list((data_dir / "mila").glob("*.jsonl"))
|
||||||
|
|
||||||
|
assert len(sleep_files) == 1
|
||||||
|
assert len(nutrition_files) == 1
|
||||||
|
assert len(mila_files) == 1
|
||||||
|
assert not (data_dir / "events").exists()
|
||||||
|
|
||||||
|
# Re-instantiate engine from disk (cold load test)
|
||||||
|
reloaded_engine = DatastoreEngine(data_dir=data_dir)
|
||||||
|
|
||||||
|
# Check that events union view contains all 3 records
|
||||||
|
all_events = reloaded_engine.query("SELECT id, kind, subject FROM events ORDER BY start ASC")
|
||||||
|
assert len(all_events) == 3
|
||||||
|
kinds = [e["kind"] for e in all_events]
|
||||||
|
assert "nutrition.intake" in kinds
|
||||||
|
assert "sleep.session" in kinds
|
||||||
|
assert "diaper.change" in kinds
|
||||||
|
|
||||||
|
# Check domain daily views
|
||||||
|
s_view = reloaded_engine.query("SELECT total_sleep_minutes FROM v_sleep_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
|
||||||
|
assert len(s_view) == 1
|
||||||
|
assert s_view[0]["total_sleep_minutes"] == 60
|
||||||
|
|
||||||
|
n_view = reloaded_engine.query("SELECT total_kcal FROM v_nutrition_daily WHERE subject = 'moritz' AND log_date = '2026-10-13'")
|
||||||
|
assert len(n_view) == 1
|
||||||
|
assert n_view[0]["total_kcal"] == 210.0
|
||||||
|
|
||||||
|
d_view = reloaded_engine.query("SELECT total_changes, wet_count FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
|
||||||
|
assert len(d_view) == 1
|
||||||
|
assert d_view[0]["total_changes"] == 1
|
||||||
|
assert d_view[0]["wet_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from datastore import DatastoreEngine
|
||||||
|
from garmin_sync import GarminSyncClient, GarminMirrorWriter, sync_date_range, generate_date_list
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_data_dir():
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="garmin_test_")
|
||||||
|
data_dir = Path(temp_dir)
|
||||||
|
# create standard dirs
|
||||||
|
(data_dir / "garmin").mkdir(parents=True)
|
||||||
|
yield data_dir
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_garmin_sync_mock_and_idempotency(temp_data_dir):
|
||||||
|
client = GarminSyncClient(token_dir=temp_data_dir, mock=True)
|
||||||
|
writer = GarminMirrorWriter(temp_data_dir)
|
||||||
|
|
||||||
|
dates = ["2026-10-01", "2026-10-02"]
|
||||||
|
sync_date_range(client, writer, dates)
|
||||||
|
|
||||||
|
daily_file = temp_data_dir / "garmin" / "daily" / "2026-10.jsonl"
|
||||||
|
sleep_file = temp_data_dir / "garmin" / "sleep" / "2026-10.jsonl"
|
||||||
|
activities_file = temp_data_dir / "garmin" / "activities" / "2026-10.jsonl"
|
||||||
|
|
||||||
|
assert daily_file.is_file()
|
||||||
|
assert sleep_file.is_file()
|
||||||
|
assert activities_file.is_file()
|
||||||
|
|
||||||
|
# Read daily lines
|
||||||
|
with open(daily_file) as f:
|
||||||
|
daily_lines = [json.loads(line) for line in f if line.strip()]
|
||||||
|
assert len(daily_lines) == 2
|
||||||
|
assert daily_lines[0]["calendarDate"] == "2026-10-01"
|
||||||
|
assert daily_lines[1]["calendarDate"] == "2026-10-02"
|
||||||
|
|
||||||
|
# Re-syncing 2026-10-01 must not create duplicates (idempotent upsert)
|
||||||
|
sync_date_range(client, writer, ["2026-10-01"])
|
||||||
|
|
||||||
|
with open(daily_file) as f:
|
||||||
|
daily_lines_after = [json.loads(line) for line in f if line.strip()]
|
||||||
|
assert len(daily_lines_after) == 2
|
||||||
|
|
||||||
|
# Check activities
|
||||||
|
with open(activities_file) as f:
|
||||||
|
act_lines = [json.loads(line) for line in f if line.strip()]
|
||||||
|
# Mock returns activityId 987654321 for each day, so deduplication keeps 1 record with latest info
|
||||||
|
assert len(act_lines) == 1
|
||||||
|
assert act_lines[0]["activityId"] == 987654321
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_date_list():
|
||||||
|
dates = generate_date_list("2026-09-28", "2026-10-01")
|
||||||
|
assert dates == ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]
|
||||||
|
|
||||||
|
# Reversed dates should automatically normalize
|
||||||
|
dates_rev = generate_date_list("2026-10-01", "2026-09-28")
|
||||||
|
assert dates_rev == ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_garmin_auth_error_handling(temp_data_dir):
|
||||||
|
# Non-mock client with empty dir should raise FileNotFoundError
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
GarminSyncClient(token_dir=temp_data_dir, mock=False)
|
||||||
@@ -31,71 +31,89 @@ OpenClaw assigns Google Calendar colors automatically based on event titles or e
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ MCP Tools
|
## 🛠️ MCP Tools & CLI Fallbacks
|
||||||
|
|
||||||
You have access to 8 dedicated calendar tools:
|
> [!IMPORTANT]
|
||||||
|
> **OpenClaw Tool Naming:** In OpenClaw, MCP tools from the `calendar` server are prefixed with `calendar__`:
|
||||||
|
> - `calendar__calendar_list_events`
|
||||||
|
> - `calendar__calendar_check_conflicts`
|
||||||
|
> - `calendar__calendar_create_event`
|
||||||
|
> - `calendar__calendar_update_event`
|
||||||
|
> - `calendar__calendar_delete_event`
|
||||||
|
> - `calendar__calendar_move_event`
|
||||||
|
> - `calendar__calendar_find_free_slots`
|
||||||
|
> - `calendar__calendar_get_event`
|
||||||
|
>
|
||||||
|
> If MCP execution is unavailable or fails with `Tool not found`, ALWAYS fall back to the direct Python CLI:
|
||||||
|
> ```bash
|
||||||
|
> # Suchen:
|
||||||
|
> /home/node/.openclaw/mcp-venv/bin/python3 /home/node/mop-skills/mop-skills/skills/google-calendar/scripts/google_calendar.py list --calendar regensburg --query "Suchbegriff"
|
||||||
|
>
|
||||||
|
> # Löschen (inkl. Serie):
|
||||||
|
> /home/node/.openclaw/mcp-venv/bin/python3 /home/node/mop-skills/mop-skills/skills/google-calendar/scripts/google_calendar.py delete <event_id> --calendar regensburg --series
|
||||||
|
> ```
|
||||||
|
|
||||||
1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`:
|
### 1. `calendar__calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`:
|
||||||
- Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description.
|
- Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description.
|
||||||
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences.
|
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences.
|
||||||
- `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`).
|
- `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`).
|
||||||
- `query`: Text filter to search summaries and descriptions.
|
- `query`: Text filter to search summaries and descriptions.
|
||||||
|
|
||||||
2. `calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`:
|
### 2. `calendar__calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`:
|
||||||
- Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer.
|
- Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer.
|
||||||
- Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`.
|
- Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`.
|
||||||
|
|
||||||
3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`:
|
### 3. `calendar__calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`:
|
||||||
- Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection.
|
- Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection.
|
||||||
- **`recurrence`**: Standard RFC 5545 rule or shortcuts:
|
- **`recurrence`**: Standard RFC 5545 rule or shortcuts:
|
||||||
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"` (weekly Saturday market)
|
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"`
|
||||||
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z"` (daily except Sun until year end)
|
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
|
||||||
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
|
- **`show_as_busy` / `transparency`**:
|
||||||
- **`show_as_busy` / `transparency`**:
|
- For **Regensburg city events, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings!
|
||||||
- For **Regensburg city events, markets, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings!
|
- **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before).
|
||||||
- **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before).
|
- **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors.
|
||||||
- **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors.
|
|
||||||
|
|
||||||
4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`:
|
### 4. `calendar__calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`:
|
||||||
- Updates fields, recurrence, transparency, reminders, or recolors an existing event.
|
- Updates fields, recurrence, transparency, reminders, or recolors an existing event.
|
||||||
- `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence.
|
- `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence.
|
||||||
|
|
||||||
5. `calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`:
|
### 5. `calendar__calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`:
|
||||||
- Deletes an event.
|
- Deletes an event.
|
||||||
- `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence.
|
- `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence.
|
||||||
|
|
||||||
6. `calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
|
### 6. `calendar__calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
|
||||||
- Moves an event atomically from one calendar to another (e.g. turning an interesting city event from Regensburg into a fixed family plan, or correcting a wrongly booked calendar).
|
- Moves an event atomically from one calendar to another.
|
||||||
|
|
||||||
7. `calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`:
|
### 7. `calendar__calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`:
|
||||||
- Queries `freebusy` to find open, available time windows across calendars.
|
- Queries `freebusy` to find open, available time windows across calendars.
|
||||||
- Perfect for answering questions like: *"Wann haben wir diese Woche am Nachmittag 2 Stunden frei für einen Ausflug?"*.
|
|
||||||
|
|
||||||
8. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
### 8. `calendar__calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
||||||
- Retrieves full event metadata.
|
- Retrieves full event metadata.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧭 Routing & Best Practices for OpenClaw
|
## 🧭 Routing & Best Practices for OpenClaw
|
||||||
|
|
||||||
|
### ⚠️ Strikte Guardrails:
|
||||||
|
1. **STRIKTE REGEL: NIEMALS ERFOLG VORTÄUSCHEN (NO FAKE CONFIRMATIONS)!** Wenn ein Tool fehlschlägt oder `not found` meldet, behaupte **niemals**, dass der Termin gelöscht oder erstellt wurde! Nutze den CLI-Fallback oder berichte den Fehler wahrheitsgemäß.
|
||||||
|
2. **STRIKTE REGEL: KEINE MÄRKTE IN DEN KALENDER EINTRAGEN!** Weder Wochenmärkte (Katharinenmarkt, Neupfarrplatz, etc.) noch Bio-Bauernmärkte dürfen in die Kalender eingetragen werden. Dies überfrachtet den Kalender.
|
||||||
|
|
||||||
### 1. Conflict Guard (Terminkonflikte & Puffer)
|
### 1. Conflict Guard (Terminkonflikte & Puffer)
|
||||||
- When a user asks to add an event, check `calendar_check_conflicts` or inspect the `conflict_warning` from `calendar_create_event`.
|
- When a user asks to add an event, check `calendar__calendar_check_conflicts` or inspect the `conflict_warning` from `calendar__calendar_create_event`.
|
||||||
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user:
|
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user.
|
||||||
> *„⚠️ Achtung: Am Freitag um 17:30 Uhr hat Moritz schon 'Moritz Gartenparty Arbeit' (17:30 - 18:30). Dein Termin 'Spielplatz' würde sich damit überschneiden. Soll ich ihn trotzdem eintragen?“*
|
|
||||||
|
|
||||||
### 2. Informational Events vs. Family Commitments (Transparency)
|
### 2. Informational Events vs. Family Commitments (Transparency)
|
||||||
- When adding city events, festivals, or weekly markets to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`).
|
- When adding city events, festivals, or exhibitions to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`).
|
||||||
- This keeps the event clearly visible on the calendar while allowing family appointments to be scheduled in parallel without false conflict alerts.
|
- This keeps the event clearly visible on the calendar while allowing family appointments to be scheduled in parallel without false conflict alerts.
|
||||||
|
|
||||||
### 3. Recurring Series (RRULE)
|
### 3. Recurring Series (RRULE)
|
||||||
- When the user asks to add recurring appointments (e.g. weekly markets, sports training, trash collection):
|
- When the user asks to add recurring appointments (e.g. sports training, trash collection):
|
||||||
- Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts.
|
- Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts.
|
||||||
- Do NOT create manual cron scripts or multiple single events when a native Google Calendar recurrence series can be used.
|
|
||||||
|
|
||||||
### 4. Smart Location & Google Maps Integration
|
### 4. Smart Location & Google Maps Integration
|
||||||
- If a place or venue is mentioned (e.g. *"Dr. Müller in Prüfening"*, *"Westbad"*):
|
- If a place or venue is mentioned (e.g. *"Dr. Müller in Prüfening"*, *"Westbad"*):
|
||||||
- Resolve the full address or ask the user, and store it in `location`.
|
- Resolve the full address or ask the user, and store it in `location`.
|
||||||
- This activates Google Maps departure alerts on their smartphone (*„Aufbruch in 15 Min. wegen Verkehr“*).
|
- This activates Google Maps departure alerts on their smartphone.
|
||||||
|
|
||||||
### 5. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
|
### 5. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
|
||||||
- When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*):
|
- When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*):
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: "heartbeat-monitor-rules"
|
||||||
|
description: "Store heartbeat monitor rules as a skill for session handling"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Heartbeat monitor rules
|
||||||
|
|
||||||
|
When a heartbeat monitor scratch context is provided:
|
||||||
|
|
||||||
|
1. Follow the scratch context as the source of truth for what to do in that turn.
|
||||||
|
2. Do not create or change recurring tasks from heartbeat messages. Use the automations tool for recurring tasks.
|
||||||
|
3. Do not infer or repeat old tasks from previous chats.
|
||||||
|
4. If the heartbeat has nothing that needs attention, respond exactly: NO_REPLY.
|
||||||
|
|
||||||
|
This skill documents the handling rules for heartbeat messages and is triggered when a heartbeat scratch context is received.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "mcp-server-debugging"
|
||||||
|
description: "MCP server tools missing or fails to start (\"Connection closed\"): reproduce via stdio client, pin mcp<2, fix writable HOME/PATH."
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Server Debugging
|
||||||
|
|
||||||
|
Diagnose and fix OpenClaw MCP servers whose tools never appear or that crash on startup.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
- A configured MCP server's tools are absent from the session, or a stdio MCP client fails `initialize` with `Connection closed`.
|
||||||
|
- A newly added MCP server does not show up as `<name>__*` tools.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Read the server definition: call `gateway` with `action=config.get`, `path=mcp`; note `command`, `args`, `env`, `cwd` for the failing server.
|
||||||
|
2. Reproduce and capture the real error: run the configured command directly with stdin from `/dev/null` (e.g. `timeout 60 python3 <launcher> < /dev/null 2>&1 | head -40`). Completion: stderr shows the underlying failure, not only the client-side "Connection closed".
|
||||||
|
3. Map the error to the fix:
|
||||||
|
- `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` → the runner pulled **mcp 2.x**, where `FastMCP` was renamed to `MCPServer`. Fix: pin 1.x, e.g. run `uvx --with "mcp<2" <package>`, or add the pin inside the launcher's `os.execvp` call.
|
||||||
|
- `Read-only file system` on a home/credentials path (launcher writes to `Path.home()/.<name>`) → `HOME` is read-only. Fix: point `HOME` to a writable dir (e.g. `/tmp`) or change the written path.
|
||||||
|
- `FileNotFoundError: uvx` → prepend the uvx directory (normally `/home/openclaw/.openclaw/bin`) to `PATH`.
|
||||||
|
4. Verify end-to-end with a minimal stdio client before declaring success: spawn the fixed command, `initialize`, `list_tools`, then call the target tool and confirm real data returns. Use `uv run --with mcp --python 3.12 python <client.py>` where the client uses `mcp.client.stdio.stdio_client` + `ClientSession`, passing `env` with the corrected `HOME`/`PATH`.
|
||||||
|
5. Route config changes through the `openclaw` tool (human approval required); never edit gateway config files directly. Report the exact old → new command.
|
||||||
|
|
||||||
|
## Tool calls fail with argument validation errors
|
||||||
|
- An MCP tool returning a pydantic `Unexpected keyword argument` error means the argument names are wrong for that server, not that the server is down: read the real schema with `tool_describe` and retry with its exact parameters.
|
||||||
|
- Home Assistant MCP: `ha_call_service` takes `{domain, service, entity_id, data}` (not `service_data`/`target`); `ha_search_entities` takes `{query}` only (no `domain`). A successful call reports `success: true` in `structuredContent`.
|
||||||
|
- A type/filter variant that errors with `'NoneType' object has no attribute 'get'` (e.g. Spotify MCP `types=playlist`) → switch to a working type (`types=track` or `album`), act on that URI, and report the server bug instead of retrying the same call.
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
- A session only exposes tools loaded at session start; a newly added server's tools appear as `<name>__*` only after a gateway reload.
|
||||||
|
- Test with the same `HOME`/`PATH` the gateway uses, otherwise you validate the wrong environment.
|
||||||
|
- Never print secret values; check presence only (`[ -n "${VAR}" ]`).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: "memory-wiki-synthesis"
|
||||||
|
description: "wiki_apply create_synthesis fails without sourceIds; create a source page first, then file the synthesis"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Filing a Memory-Wiki Synthesis
|
||||||
|
|
||||||
|
Use when filing a research dossier or synthesis into the memory wiki via `wiki_apply` with `op: "create_synthesis"`.
|
||||||
|
|
||||||
|
1. Call `wiki_apply` with `op: "create_synthesis"`, `title`, and `body`. If it returns `wiki mutation requires at least one sourceId for create_synthesis`, do not retry blind. Completion: the error names the missing `sourceIds`.
|
||||||
|
2. Create a source page first. Read the vault path from `wiki_status` (`vaultPath`, e.g. `/home/openclaw/.openclaw/wiki/main`), then write `sources/YYYY-MM-DD-<slug>.md` with frontmatter `type: Source`, a unique `id: <slug>`, `title`, and a `sources:` list of the URLs used. Completion: the file exists and carries an `id`.
|
||||||
|
3. Re-run `wiki_apply` `op: "create_synthesis"` passing `sourceIds: ["<slug>"]` (the source page `id`) plus `title`, `body`, and any `questions`/`confidence`. Completion: the response reports the new `syntheses/...` page path and refreshes the index files.
|
||||||
|
4. Run `wiki_lint` after the update. Completion: lint returns; expect a large pre-existing baseline of structural warnings, so only act on issues for the pages just written.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: "pdf-onepager"
|
||||||
|
description: "Generate styled one-page PDFs in the sandbox via fpdf2 and print with brother-printer."
|
||||||
|
---
|
||||||
|
|
||||||
|
# PDF One-Pager (fpdf2)
|
||||||
|
|
||||||
|
Generate a styled, single-page PDF in the sandbox, then print it with the brother-printer skill.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Draft the content as a Python script using `fpdf2` (FPDF, A4, `set_auto_page_break(False)`, margins ~15mm). Build layout with helper functions: colored title, section headers with rule line, label+text bullets, a filled warning box (`set_fill_color` + `multi_cell(fill=True)`), and a small gray source footer. Do not use emoji/HTML-entities in text: fpdf2 core fonts (helvetica) support only Latin-1 — replace emoji with plain words.
|
||||||
|
- Done when: the script is saved (e.g. `/tmp/make_pdf.py`) with an absolute output path and prints a pages count from `pdf.pages_count`.
|
||||||
|
2. Run `uv run --with fpdf2 /tmp/make_pdf.py`.
|
||||||
|
- Do NOT attempt `weasyprint` (fails in this sandbox: missing system libs) or wkhtmltopdf/chromium/pandoc (not installed). Go straight to fpdf2.
|
||||||
|
- Done when: script prints `done, pages: 1` (or expected count) and the PDF file exists.
|
||||||
|
3. Verify the file (`ls -la <path>`), then print per the brother-printer skill: `uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print --file <pdf> --title <title> --color monochrome --nup 1 --duplex two-sided-long-edge --chat-id <chat_id>`.
|
||||||
|
- For print-option selection, use `ask_user` with the standard preset menu from the brother-printer skill.
|
||||||
|
- Done when: script reports 'Druck erfolgreich abgeschlossen'.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
- fpdf2 API: `pdf.cell(w, h, text, new_x="LMARGIN", new_y="NEXT")` for labels/headers; `pdf.multi_cell(W, h, text, new_x="LMARGIN", new_y="NEXT")` for wrapped text; colors via `set_text_color/set_fill_color/set_draw_color(RGB tuples)`.
|
||||||
|
- Keep single-page fit: font 9–9.5pt body, 11–11.5pt headers, 17pt title; if content overflows, trim bullets rather than shrinking below 9pt.
|
||||||
|
- Save the PDF in the workspace (e.g. `/home/openclaw/.openclaw/workspace/<Name>.pdf`) so it can be resent later.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "sonos-media-playback"
|
||||||
|
description: "Play music on Sonos speakers via Home Assistant; when Spotify search or play_media fails, use Sonos source_list favorites."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sonos Media Playback
|
||||||
|
|
||||||
|
Play music (genre, playlist, artist, "50% volume") on a room Sonos via Home Assistant MCP.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Find the room player: `homeassistant__ha_search_entities` with `domain_filter="media_player"` and the room name (German room names match, e.g. "bad" -> `media_player.bad`). Done when one media_player entity is found.
|
||||||
|
2. Set volume first with `ha_call_service(domain="media_player", service="volume_set", data={"volume_level": <0-1>})` — this works even while the player is paused/idle. Percent requested / 100.
|
||||||
|
3. If the player is `paused` or `idle`, wake it with `media_player.media_play` so Spotify Connect may register it.
|
||||||
|
4. Resolve the requested music. Prefer, in order:
|
||||||
|
- `spotify__search` for playlist/track URIs, then play via the Spotify MCP `play` tool. If `play` returns 404 "No active device found": call `spotify__get_devices`, pick the room-matching device, and retry `play` with that `device_id` explicitly.
|
||||||
|
- If Spotify search errors or no Spotify device is active: get the player state with `ha_get_state` and read its `source_list` (Sonos favorites). Pick the favorite closest to the requested genre (e.g. dark techno -> `ChilloutTechno`, sets -> `Fusion 2026 Sets`), then `ha_call_service(domain="media_player", service="select_source", data={"source": "<favorite>"})`.
|
||||||
|
5. Verify: `ha_get_state` on the entity shows `playing` with `media_title`/`media_playlist` and the requested `volume_level`. Report title and volume to the user; offer the nearest alternative favorite if the genre match is loose.
|
||||||
|
|
||||||
|
## Do not retry
|
||||||
|
|
||||||
|
- `ha_call_service(media_player, play_media)` with any `spotify:` URI on Sonos: returns HTTP 500 regardless of `media_content_type` (`music`/`playlist`) — skip it and go straight to `source_list` favorites.
|
||||||
|
- Repeated retries of a failing `spotify__search` with different arguments: the failure is server-side; fall back once, then use favorites.
|
||||||