feat(family): deploy Single-Writer DuckDB backend, Mila parent cockpit & feeding analytics, and Moritz macro tracker

This commit is contained in:
Moritz Graf
2026-10-17 07:21:49 +02:00
parent ef50d87bd7
commit 5b8432caf4
96 changed files with 16409 additions and 29 deletions
+1
View File
@@ -0,0 +1 @@
# app package
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from pathlib import Path
def find_default_wiki_dir() -> Path:
env_wiki = os.environ.get("WIKI_DIR") or os.environ.get("GIT_REPO_DIR")
if env_wiki:
return Path(env_wiki).resolve()
candidates = [
Path("/wiki"),
Path.cwd() / "knowledge-wiki",
Path.cwd().parent.parent / "knowledge-wiki",
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
Path.home() / "src" / "infrapuzzle" / "knowledge-wiki",
Path("/home/node/.openclaw/wiki/main"),
]
for c in candidates:
if c.is_dir() and (c / "data").is_dir():
return c.resolve()
return (Path.cwd() / "knowledge-wiki").resolve()
WIKI_DIR = find_default_wiki_dir()
DATA_DIR = Path(
os.environ.get("DATA_DIR")
or os.environ.get("DUCKDB_DATA_DIR")
or (WIKI_DIR / "data")
).resolve()
GIT_REPO_URL = os.environ.get(
"GIT_REPO_URL",
os.environ.get("GIT_REMOTE_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git"),
)
GIT_USERNAME = os.environ.get("GIT_SYNC_USERNAME", "")
GIT_PASSWORD = os.environ.get("GIT_SYNC_PASSWORD", os.environ.get("WIKI_SYNC_TOKEN", ""))
GIT_BRANCH = os.environ.get("GIT_BRANCH", "main")
GIT_SYNC_INTERVAL_SECONDS = int(os.environ.get("GIT_SYNC_INTERVAL_SECONDS", "60"))
AUTO_GIT_SYNC = os.environ.get("AUTO_GIT_SYNC", "true").lower() in ("1", "true", "yes")
GIT_AUTHOR_NAME = os.environ.get("GIT_AUTHOR_NAME", "DuckDB Backend")
GIT_AUTHOR_EMAIL = os.environ.get("GIT_AUTHOR_EMAIL", "duckdb-backend@haumdaucher.de")
PORT = int(os.environ.get("PORT", "8000"))
HOST = os.environ.get("HOST", "0.0.0.0")
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import asyncio
import datetime
import logging
import os
import subprocess
import threading
import urllib.parse
from pathlib import Path
from typing import Callable, Optional
from app.config import (
AUTO_GIT_SYNC,
GIT_AUTHOR_EMAIL,
GIT_AUTHOR_NAME,
GIT_BRANCH,
GIT_PASSWORD,
GIT_REPO_URL,
GIT_SYNC_INTERVAL_SECONDS,
GIT_USERNAME,
WIKI_DIR,
)
logger = logging.getLogger("duckdb_backend.git_sync")
class GitSyncManager:
def __init__(self, wiki_dir: Optional[Path] = None, on_data_pulled: Optional[Callable[[], None]] = None):
self.wiki_dir = wiki_dir or WIKI_DIR
self.on_data_pulled = on_data_pulled
self._lock = threading.Lock()
self.status = "ok"
self.is_conflict = False
self.last_sync_time: Optional[str] = None
self.last_commit_hash: Optional[str] = None
self.last_error: Optional[str] = None
self._bg_task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
def _configure_git_client(self):
"""Configures git client with safe.directory and author identity."""
try:
subprocess.run(["git", "config", "--global", "http.sslVerify", "false"], check=False)
subprocess.run(["git", "config", "--global", "--add", "safe.directory", "*"], check=False)
if (self.wiki_dir / ".git").is_dir():
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.name", GIT_AUTHOR_NAME], check=False)
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.email", GIT_AUTHOR_EMAIL], check=False)
except Exception as e:
logger.warning(f"Failed to configure git client options: {e}")
def get_auth_repo_url(self) -> str:
if GIT_USERNAME and GIT_PASSWORD and "://" in GIT_REPO_URL:
proto, rest = GIT_REPO_URL.split("://", 1)
# Remove existing credentials in URL if any
if "@" in rest:
rest = rest.split("@", 1)[1]
user_enc = urllib.parse.quote(GIT_USERNAME, safe="")
pass_enc = urllib.parse.quote(GIT_PASSWORD, safe="")
return f"{proto}://{user_enc}:{pass_enc}@{rest}"
return GIT_REPO_URL
def ensure_repo(self) -> bool:
"""Ensures repository exists and is initialized."""
with self._lock:
self._configure_git_client()
if (self.wiki_dir / ".git").is_dir():
try:
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
self.last_commit_hash = res.stdout.strip()
return True
except Exception as e:
logger.warning(f"Failed to check git rev: {e}")
return True
if not GIT_REPO_URL:
logger.info(f"Wiki directory {self.wiki_dir} is not a git repo, but no GIT_REPO_URL configured.")
return False
logger.info(f"Initializing repository {GIT_REPO_URL} in {self.wiki_dir}...")
self.wiki_dir.mkdir(parents=True, exist_ok=True)
auth_url = self.get_auth_repo_url()
try:
# Use git init + remote + fetch + checkout to safely support non-empty directories (e.g. lost+found on PVC)
subprocess.run(["git", "-C", str(self.wiki_dir), "init"], capture_output=True, check=False)
self._configure_git_client()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "add", "origin", auth_url],
capture_output=True,
check=False,
)
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=60,
)
if res.returncode == 0:
if (self.wiki_dir / ".git" / "shallow").exists():
subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "--unshallow", "origin", GIT_BRANCH],
capture_output=True,
check=False,
)
checkout_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "checkout", "-f", "-B", GIT_BRANCH, f"origin/{GIT_BRANCH}"],
capture_output=True,
text=True,
check=False,
)
if checkout_res.returncode == 0:
logger.info("Successfully initialized and checked out wiki repository.")
self.status = "ok"
self.is_conflict = False
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
return True
else:
self.status = "error"
self.last_error = f"Checkout failed: {checkout_res.stderr.strip()}"
logger.error(self.last_error)
return False
else:
self.status = "error"
self.last_error = f"Fetch failed: {res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Clone exception: {e}"
logger.error(self.last_error)
return False
def pull_rebase(self) -> bool:
"""Pulls latest changes via rebase. Handles conflicts cleanly."""
if not (self.wiki_dir / ".git").is_dir():
return False
with self._lock:
try:
# Update remote url with credentials if provided
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
# Fetch and rebase with autostash
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
# Update commit hash
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
if self.on_data_pulled:
try:
self.on_data_pulled()
except Exception as ex:
logger.error(f"Error in on_data_pulled callback: {ex}")
return True
else:
# Check if conflict occurred
stderr = res.stderr or res.stdout
logger.warning(f"Git pull --rebase failed: {stderr}")
# Abort any in-progress rebase to keep working tree stable
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict: {stderr.strip()}"
return False
except Exception as e:
self.status = "error"
self.last_error = f"Pull error: {e}"
logger.error(self.last_error)
return False
def commit_and_push(self, commit_msg: str) -> bool:
"""Stages data/, commits with descriptive message, rebases and pushes."""
if not (self.wiki_dir / ".git").is_dir():
return False
with self._lock:
try:
self._configure_git_client()
data_rel = "data"
subprocess.run(
["git", "-C", str(self.wiki_dir), "add", data_rel],
capture_output=True,
check=False,
)
commit_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "commit", "-m", f"{commit_msg} [skip ci]"],
capture_output=True,
text=True,
check=False,
)
if commit_res.returncode != 0:
# Nothing to commit or already committed
logger.debug(f"Commit returncode {commit_res.returncode}: {commit_res.stdout.strip()}")
return True
# Pull rebase with autostash before push
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
rebase_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if rebase_res.returncode != 0:
stderr = rebase_res.stderr or rebase_res.stdout
logger.warning(f"Rebase failed during commit_and_push: {stderr}")
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict on push: {stderr.strip()}"
return False
# Push to remote
push_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "push", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if push_res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
return True
else:
self.status = "error"
self.last_error = f"Push error: {push_res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Commit/push error: {e}"
logger.error(self.last_error)
return False
async def start_periodic_sync(self):
"""Starts background periodic git pull loop."""
if not AUTO_GIT_SYNC:
logger.info("Automatic git sync is disabled by configuration.")
return
self._stop_event.clear()
async def _loop():
logger.info(f"Starting periodic git sync every {GIT_SYNC_INTERVAL_SECONDS}s")
while not self._stop_event.is_set():
try:
await asyncio.to_thread(self.pull_rebase)
except Exception as ex:
logger.error(f"Error during periodic git pull: {ex}")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=GIT_SYNC_INTERVAL_SECONDS)
except asyncio.TimeoutError:
pass
self._bg_task = asyncio.create_task(_loop())
async def stop_periodic_sync(self):
"""Stops background sync loop."""
self._stop_event.set()
if self._bg_task:
self._bg_task.cancel()
try:
await self._bg_task
except asyncio.CancelledError:
pass
self._bg_task = None
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import DATA_DIR, WIKI_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.routes.dashboard import router as dashboard_router
from app.routes.entities import router as entities_router
from app.routes.events import router as events_router
from app.routes.query import router as query_router
from app.routes.system import router as system_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("duckdb_backend")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Initializing DuckDB Backend...")
git_sync = GitSyncManager(
wiki_dir=WIKI_DIR,
on_data_pulled=lambda: app.state.engine.reload_all() if hasattr(app.state, "engine") else None,
)
git_sync.ensure_repo()
app.state.git_sync = git_sync
engine = DuckDBEngine(data_dir=DATA_DIR, git_sync_manager=git_sync)
app.state.engine = engine
await git_sync.start_periodic_sync()
logger.info("DuckDB Backend is ready to serve queries.")
yield
logger.info("Shutting down DuckDB Backend...")
await git_sync.stop_periodic_sync()
app = FastAPI(
title="DuckDB Backend for Knowledge Wiki",
version="1.0.0",
description="HTTP-based analytical and transactional backend for personal logging data powered by DuckDB",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API v1 prefix
v1_router = FastAPI()
app.include_router(dashboard_router, prefix="/duckdb-api/v1")
app.include_router(query_router, prefix="/duckdb-api/v1")
app.include_router(events_router, prefix="/duckdb-api/v1")
app.include_router(entities_router, prefix="/duckdb-api/v1")
app.include_router(system_router, prefix="/duckdb-api/v1")
# Direct root health probes for Kubernetes
app.include_router(system_router)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
query: str = Field(..., description="Read-only SQL query to execute in DuckDB")
params: Optional[List[Any]] = Field(default=None, description="Positional query parameters")
class QueryResponse(BaseModel):
columns: List[str]
rows: List[Dict[str, Any]]
count: int
execution_time_ms: float
class EventCreateRequest(BaseModel):
kind: str = Field(..., description="Event kind, e.g. sleep.session, nutrition.intake, diaper.change, feeding.bottle, growth.measurement")
subject: str = Field(..., description="Event subject, e.g. mila or moritz")
payload: Dict[str, Any] = Field(default_factory=dict, description="Event-specific payload")
actor: Optional[str] = Field(default="web-app", description="Actor who logged the event")
date: Optional[str] = Field(default=None, description="Explicit log date override YYYY-MM-DD")
start: Optional[str] = Field(default=None, description="ISO-8601 start timestamp")
end: Optional[str] = Field(default=None, description="ISO-8601 end timestamp (optional)")
since: Optional[str] = Field(default=None, description="Relative start duration like '15m', '1h'")
estimated: bool = Field(default=False, description="Whether intake is estimated")
meal: Optional[str] = Field(default=None, description="Meal category, e.g. fruehstueck, mittagessen, abendessen, snack")
note: Optional[str] = Field(default=None, description="Free-text note")
source_ref: Optional[str] = Field(default=None, description="Source reference")
allow_duplicate: bool = Field(default=False, description="Allow event within 15min of duplicate")
class EventStopRequest(BaseModel):
kind: str = Field(default="sleep.session", description="Event kind to stop")
subject: str = Field(..., description="Event subject, e.g. mila")
end_time: Optional[str] = Field(default=None, description="ISO-8601 end timestamp")
since: Optional[str] = Field(default=None, description="Relative end duration like '5m'")
event_id: Optional[str] = Field(default=None, description="Specific event ID to close")
actor: Optional[str] = Field(default="web-app", description="Actor who stopped the event")
class EntityPutRequest(BaseModel):
collection: str = Field(..., description="Target collection: foods, recipes, places, people")
record: Dict[str, Any] = Field(..., description="Entity record with 'id'")
class GarminSyncRequest(BaseModel):
daily: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin daily summary records")
sleep: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin sleep records")
activities: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin activity records")
class StatusResponse(BaseModel):
status: str
git_conflict: bool
last_sync_time: Optional[str]
last_commit_hash: Optional[str]
last_error: Optional[str]
data_dir: str
table_counts: Dict[str, int]
@@ -0,0 +1 @@
# routes package
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
router = APIRouter(tags=["Dashboards"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.get("/mila/dashboard")
async def get_mila_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/dashboard")
async def get_moritz_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/mila/day/{date}")
async def get_mila_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/day/{date}")
async def get_moritz_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/summary/{subject}")
async def get_summary(
subject: str,
date: Optional[str] = Query(default=None, description="Target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_summary(subject=subject, date_str=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,51 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
from app.models import EntityPutRequest
router = APIRouter(tags=["Entities"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/entities")
async def put_entity(
req: EntityPutRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.put_entity(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/entities/{collection}")
async def list_entities(
collection: str,
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.list_entities(collection=collection)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/places/near")
async def find_places_near(
lat: float = Query(..., description="Latitude"),
lon: float = Query(..., description="Longitude"),
radius_m: float = Query(default=1000.0, description="Radius in meters"),
category: Optional[str] = Query(default=None, description="Category filter"),
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.find_places_near(lat=lat, lon=lon, radius_m=radius_m, category=category)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import EventCreateRequest, EventStopRequest, GarminSyncRequest
router = APIRouter(tags=["Events"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/events")
async def add_event(
req: EventCreateRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.add_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/events/stop")
async def stop_event(
req: EventStopRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.stop_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/events/{event_id}")
async def delete_event(
event_id: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.delete_event(event_id)
except ValueError as ve:
raise HTTPException(status_code=404, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/garmin/sync")
async def sync_garmin(
req: GarminSyncRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.sync_garmin(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,35 @@
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import QueryRequest, QueryResponse
router = APIRouter(tags=["Generic Queries"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/query", response_model=QueryResponse)
async def execute_query(
req: QueryRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> QueryResponse:
t0 = time.perf_counter()
try:
rows = engine.query(req.query, req.params)
duration_ms = round((time.perf_counter() - t0) * 1000, 2)
cols = list(rows[0].keys()) if rows else []
return QueryResponse(
columns=cols,
rows=rows,
count=len(rows),
execution_time_ms=duration_ms,
)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,77 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.config import DATA_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.models import StatusResponse
router = APIRouter(tags=["System"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
def get_git_sync(request: Request) -> Optional[GitSyncManager]:
return getattr(request.app.state, "git_sync", None)
@router.get("/healthz")
async def healthz(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
conflict = git_sync.is_conflict if git_sync else False
status_str = "conflict" if conflict else "ok"
return {
"status": status_str,
"conflict": conflict,
"engine_ready": True,
}
@router.get("/status", response_model=StatusResponse)
async def status(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> StatusResponse:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
counts = engine.get_table_counts()
return StatusResponse(
status=git_sync.status if git_sync else "ok",
git_conflict=git_sync.is_conflict if git_sync else False,
last_sync_time=git_sync.last_sync_time if git_sync else None,
last_commit_hash=git_sync.last_commit_hash if git_sync else None,
last_error=git_sync.last_error if git_sync else None,
data_dir=str(engine.data_dir),
table_counts=counts,
)
@router.post("/git/sync")
async def trigger_sync(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
if not git_sync:
raise HTTPException(status_code=400, detail="Git sync is not configured")
success = git_sync.pull_rebase()
if success:
engine.reload_all()
return {
"status": "ok",
"message": "Git pull --rebase completed and DuckDB reloaded.",
"commit": git_sync.last_commit_hash,
}
else:
return {
"status": git_sync.status,
"conflict": git_sync.is_conflict,
"error": git_sync.last_error,
}
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional, Tuple
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]:
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]:
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",
}
if abs(L) > 1e-6:
z = ((value / M) ** L - 1.0) / (L * S)
else:
z = math.log(value / M) / S
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,
}