Compare commits
No commits in common. "main" and "v9b-refactor-phase1-passwordfix" have entirely different histories.
main
...
v9b-refact
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -60,5 +60,4 @@ tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
#.claude Konfiguration
|
#.claude Konfiguration
|
||||||
.claude/
|
.claude/
|
||||||
.claude/settings.local.jsonfrontend/package-lock.json
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Apply v9c Subscription System Migration
|
|
||||||
|
|
||||||
This script checks if v9c migration is needed and applies it.
|
|
||||||
Run automatically on container startup via main.py startup event.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_connection():
|
|
||||||
"""Get PostgreSQL connection."""
|
|
||||||
return psycopg2.connect(
|
|
||||||
host=os.getenv("DB_HOST", "postgres"),
|
|
||||||
port=int(os.getenv("DB_PORT", 5432)),
|
|
||||||
database=os.getenv("DB_NAME", "mitai_prod"),
|
|
||||||
user=os.getenv("DB_USER", "mitai_prod"),
|
|
||||||
password=os.getenv("DB_PASSWORD", ""),
|
|
||||||
cursor_factory=RealDictCursor
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def migration_needed(conn):
|
|
||||||
"""Check if v9c migration is needed."""
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
# Check if tiers table exists
|
|
||||||
cur.execute("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'tiers'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
tiers_exists = cur.fetchone()['exists']
|
|
||||||
|
|
||||||
# Check if features table exists
|
|
||||||
cur.execute("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'features'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
features_exists = cur.fetchone()['exists']
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
# Migration needed if either table is missing
|
|
||||||
return not (tiers_exists and features_exists)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_migration():
|
|
||||||
"""Apply v9c migration if needed."""
|
|
||||||
print("[v9c Migration] Checking if migration is needed...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = get_db_connection()
|
|
||||||
|
|
||||||
if not migration_needed(conn):
|
|
||||||
print("[v9c Migration] Already applied, skipping.")
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Even if main migration is done, check cleanup
|
|
||||||
apply_cleanup_migration()
|
|
||||||
return
|
|
||||||
|
|
||||||
print("[v9c Migration] Applying subscription system migration...")
|
|
||||||
|
|
||||||
# Read migration SQL
|
|
||||||
migration_path = os.path.join(
|
|
||||||
os.path.dirname(__file__),
|
|
||||||
"migrations",
|
|
||||||
"v9c_subscription_system.sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(migration_path, 'r', encoding='utf-8') as f:
|
|
||||||
migration_sql = f.read()
|
|
||||||
|
|
||||||
# Execute migration
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(migration_sql)
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print("[v9c Migration] ✅ Migration completed successfully!")
|
|
||||||
|
|
||||||
# Apply fix migration if exists
|
|
||||||
fix_migration_path = os.path.join(
|
|
||||||
os.path.dirname(__file__),
|
|
||||||
"migrations",
|
|
||||||
"v9c_fix_features.sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
if os.path.exists(fix_migration_path):
|
|
||||||
print("[v9c Migration] Applying feature fixes...")
|
|
||||||
with open(fix_migration_path, 'r', encoding='utf-8') as f:
|
|
||||||
fix_sql = f.read()
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(fix_sql)
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
print("[v9c Migration] ✅ Feature fixes applied!")
|
|
||||||
|
|
||||||
# Verify tables created
|
|
||||||
conn = get_db_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT table_name FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name IN ('tiers', 'features', 'tier_limits', 'access_grants', 'coupons')
|
|
||||||
ORDER BY table_name
|
|
||||||
""")
|
|
||||||
tables = [r['table_name'] for r in cur.fetchall()]
|
|
||||||
print(f"[v9c Migration] Created tables: {', '.join(tables)}")
|
|
||||||
|
|
||||||
# Verify initial data
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM tiers")
|
|
||||||
tier_count = cur.fetchone()['count']
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM features")
|
|
||||||
feature_count = cur.fetchone()['count']
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM tier_limits")
|
|
||||||
limit_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
print(f"[v9c Migration] Initial data: {tier_count} tiers, {feature_count} features, {limit_count} tier limits")
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# After successful migration, apply cleanup
|
|
||||||
apply_cleanup_migration()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[v9c Migration] ❌ Error: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_features_needed(conn):
|
|
||||||
"""Check if feature cleanup migration is needed."""
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
# Check if old export features still exist
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count FROM features
|
|
||||||
WHERE id IN ('export_csv', 'export_json', 'export_zip')
|
|
||||||
""")
|
|
||||||
old_exports = cur.fetchone()['count']
|
|
||||||
|
|
||||||
# Check if csv_import needs to be renamed
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count FROM features
|
|
||||||
WHERE id = 'csv_import'
|
|
||||||
""")
|
|
||||||
old_import = cur.fetchone()['count']
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
# Cleanup needed if old features exist
|
|
||||||
return old_exports > 0 or old_import > 0
|
|
||||||
|
|
||||||
|
|
||||||
def apply_cleanup_migration():
|
|
||||||
"""Apply v9c feature cleanup migration."""
|
|
||||||
print("[v9c Cleanup] Checking if cleanup migration is needed...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = get_db_connection()
|
|
||||||
|
|
||||||
if not cleanup_features_needed(conn):
|
|
||||||
print("[v9c Cleanup] Already applied, skipping.")
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
print("[v9c Cleanup] Applying feature consolidation...")
|
|
||||||
|
|
||||||
# Show BEFORE state
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT id, name FROM features ORDER BY category, id")
|
|
||||||
features_before = [f"{r['id']} ({r['name']})" for r in cur.fetchall()]
|
|
||||||
print(f"[v9c Cleanup] Features BEFORE: {len(features_before)} features")
|
|
||||||
for f in features_before:
|
|
||||||
print(f" - {f}")
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
# Read cleanup migration SQL
|
|
||||||
cleanup_path = os.path.join(
|
|
||||||
os.path.dirname(__file__),
|
|
||||||
"migrations",
|
|
||||||
"v9c_cleanup_features.sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not os.path.exists(cleanup_path):
|
|
||||||
print(f"[v9c Cleanup] ⚠️ Cleanup migration file not found: {cleanup_path}")
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(cleanup_path, 'r', encoding='utf-8') as f:
|
|
||||||
cleanup_sql = f.read()
|
|
||||||
|
|
||||||
# Execute cleanup migration
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(cleanup_sql)
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
# Show AFTER state
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT id, name, category FROM features ORDER BY category, id")
|
|
||||||
features_after = cur.fetchall()
|
|
||||||
print(f"[v9c Cleanup] Features AFTER: {len(features_after)} features")
|
|
||||||
|
|
||||||
# Group by category
|
|
||||||
categories = {}
|
|
||||||
for f in features_after:
|
|
||||||
cat = f['category'] or 'other'
|
|
||||||
if cat not in categories:
|
|
||||||
categories[cat] = []
|
|
||||||
categories[cat].append(f"{f['id']} ({f['name']})")
|
|
||||||
|
|
||||||
for cat, feats in sorted(categories.items()):
|
|
||||||
print(f" {cat.upper()}:")
|
|
||||||
for f in feats:
|
|
||||||
print(f" - {f}")
|
|
||||||
|
|
||||||
# Verify tier_limits updated
|
|
||||||
cur.execute("""
|
|
||||||
SELECT tier_id, feature_id, limit_value
|
|
||||||
FROM tier_limits
|
|
||||||
WHERE feature_id IN ('data_export', 'data_import')
|
|
||||||
ORDER BY tier_id, feature_id
|
|
||||||
""")
|
|
||||||
limits = cur.fetchall()
|
|
||||||
print(f"[v9c Cleanup] Tier limits for data_export/data_import:")
|
|
||||||
for lim in limits:
|
|
||||||
limit_str = 'unlimited' if lim['limit_value'] is None else lim['limit_value']
|
|
||||||
print(f" {lim['tier_id']}.{lim['feature_id']} = {limit_str}")
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print("[v9c Cleanup] ✅ Feature cleanup completed successfully!")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[v9c Cleanup] ❌ Error: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
apply_migration()
|
|
||||||
258
backend/auth.py
258
backend/auth.py
|
|
@ -7,7 +7,6 @@ for FastAPI endpoints.
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import Header, Query, HTTPException
|
from fastapi import Header, Query, HTTPException
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
|
|
@ -115,260 +114,3 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
|
||||||
if session['role'] != 'admin':
|
if session['role'] != 'admin':
|
||||||
raise HTTPException(403, "Nur für Admins")
|
raise HTTPException(403, "Nur für Admins")
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Feature Access Control (v9c)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_effective_tier(profile_id: str, conn=None) -> str:
|
|
||||||
"""
|
|
||||||
Get the effective tier for a profile.
|
|
||||||
|
|
||||||
Checks for active access_grants first (from coupons, trials, etc.),
|
|
||||||
then falls back to profile.tier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
conn: Optional existing DB connection (to avoid pool exhaustion)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tier_id (str): 'free', 'basic', 'premium', or 'selfhosted'
|
|
||||||
"""
|
|
||||||
# Use existing connection if provided, otherwise open new one
|
|
||||||
if conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check for active access grants (highest priority)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT tier_id
|
|
||||||
FROM access_grants
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND is_active = true
|
|
||||||
AND valid_from <= CURRENT_TIMESTAMP
|
|
||||||
AND valid_until > CURRENT_TIMESTAMP
|
|
||||||
ORDER BY valid_until DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
grant = cur.fetchone()
|
|
||||||
if grant:
|
|
||||||
return grant['tier_id']
|
|
||||||
|
|
||||||
# Fall back to profile tier
|
|
||||||
cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,))
|
|
||||||
profile = cur.fetchone()
|
|
||||||
return profile['tier'] if profile else 'free'
|
|
||||||
else:
|
|
||||||
# Open new connection if none provided
|
|
||||||
with get_db() as conn:
|
|
||||||
return get_effective_tier(profile_id, conn)
|
|
||||||
|
|
||||||
|
|
||||||
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
|
||||||
"""
|
|
||||||
Check if a profile has access to a feature.
|
|
||||||
|
|
||||||
Access hierarchy:
|
|
||||||
1. User-specific restriction (user_feature_restrictions)
|
|
||||||
2. Tier limit (tier_limits)
|
|
||||||
3. Feature default (features.default_limit)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
feature_id: Feature ID to check
|
|
||||||
conn: Optional existing DB connection (to avoid pool exhaustion)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {
|
|
||||||
'allowed': bool,
|
|
||||||
'limit': int | None, # NULL = unlimited
|
|
||||||
'used': int,
|
|
||||||
'remaining': int | None, # NULL = unlimited
|
|
||||||
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# Use existing connection if provided
|
|
||||||
if conn:
|
|
||||||
return _check_impl(profile_id, feature_id, conn)
|
|
||||||
else:
|
|
||||||
with get_db() as conn:
|
|
||||||
return _check_impl(profile_id, feature_id, conn)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
|
||||||
"""Internal implementation of check_feature_access."""
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get feature info
|
|
||||||
cur.execute("""
|
|
||||||
SELECT limit_type, reset_period, default_limit
|
|
||||||
FROM features
|
|
||||||
WHERE id = %s AND active = true
|
|
||||||
""", (feature_id,))
|
|
||||||
feature = cur.fetchone()
|
|
||||||
|
|
||||||
if not feature:
|
|
||||||
return {
|
|
||||||
'allowed': False,
|
|
||||||
'limit': None,
|
|
||||||
'used': 0,
|
|
||||||
'remaining': None,
|
|
||||||
'reason': 'feature_not_found'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Priority 1: Check user-specific restriction
|
|
||||||
cur.execute("""
|
|
||||||
SELECT limit_value
|
|
||||||
FROM user_feature_restrictions
|
|
||||||
WHERE profile_id = %s AND feature_id = %s
|
|
||||||
""", (profile_id, feature_id))
|
|
||||||
restriction = cur.fetchone()
|
|
||||||
|
|
||||||
if restriction is not None:
|
|
||||||
limit = restriction['limit_value']
|
|
||||||
else:
|
|
||||||
# Priority 2: Check tier limit
|
|
||||||
tier_id = get_effective_tier(profile_id, conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT limit_value
|
|
||||||
FROM tier_limits
|
|
||||||
WHERE tier_id = %s AND feature_id = %s
|
|
||||||
""", (tier_id, feature_id))
|
|
||||||
tier_limit = cur.fetchone()
|
|
||||||
|
|
||||||
if tier_limit is not None:
|
|
||||||
limit = tier_limit['limit_value']
|
|
||||||
else:
|
|
||||||
# Priority 3: Feature default
|
|
||||||
limit = feature['default_limit']
|
|
||||||
|
|
||||||
# For boolean features (limit 0 = disabled, 1 = enabled)
|
|
||||||
if feature['limit_type'] == 'boolean':
|
|
||||||
allowed = limit == 1
|
|
||||||
return {
|
|
||||||
'allowed': allowed,
|
|
||||||
'limit': limit,
|
|
||||||
'used': 0,
|
|
||||||
'remaining': None,
|
|
||||||
'reason': 'enabled' if allowed else 'feature_disabled'
|
|
||||||
}
|
|
||||||
|
|
||||||
# For count-based features
|
|
||||||
# Check current usage
|
|
||||||
cur.execute("""
|
|
||||||
SELECT usage_count, reset_at
|
|
||||||
FROM user_feature_usage
|
|
||||||
WHERE profile_id = %s AND feature_id = %s
|
|
||||||
""", (profile_id, feature_id))
|
|
||||||
usage = cur.fetchone()
|
|
||||||
|
|
||||||
used = usage['usage_count'] if usage else 0
|
|
||||||
|
|
||||||
# Check if reset is needed
|
|
||||||
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
|
|
||||||
# Reset usage
|
|
||||||
used = 0
|
|
||||||
next_reset = _calculate_next_reset(feature['reset_period'])
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE user_feature_usage
|
|
||||||
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
|
|
||||||
WHERE profile_id = %s AND feature_id = %s
|
|
||||||
""", (next_reset, profile_id, feature_id))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# NULL limit = unlimited
|
|
||||||
if limit is None:
|
|
||||||
return {
|
|
||||||
'allowed': True,
|
|
||||||
'limit': None,
|
|
||||||
'used': used,
|
|
||||||
'remaining': None,
|
|
||||||
'reason': 'unlimited'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 0 limit = disabled
|
|
||||||
if limit == 0:
|
|
||||||
return {
|
|
||||||
'allowed': False,
|
|
||||||
'limit': 0,
|
|
||||||
'used': used,
|
|
||||||
'remaining': 0,
|
|
||||||
'reason': 'feature_disabled'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if within limit
|
|
||||||
allowed = used < limit
|
|
||||||
remaining = limit - used if limit else None
|
|
||||||
|
|
||||||
return {
|
|
||||||
'allowed': allowed,
|
|
||||||
'limit': limit,
|
|
||||||
'used': used,
|
|
||||||
'remaining': remaining,
|
|
||||||
'reason': 'within_limit' if allowed else 'limit_exceeded'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
|
||||||
"""
|
|
||||||
Increment usage counter for a feature.
|
|
||||||
|
|
||||||
Creates usage record if it doesn't exist, with reset_at based on
|
|
||||||
feature's reset_period.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get feature reset period
|
|
||||||
cur.execute("""
|
|
||||||
SELECT reset_period
|
|
||||||
FROM features
|
|
||||||
WHERE id = %s
|
|
||||||
""", (feature_id,))
|
|
||||||
feature = cur.fetchone()
|
|
||||||
|
|
||||||
if not feature:
|
|
||||||
return
|
|
||||||
|
|
||||||
reset_period = feature['reset_period']
|
|
||||||
next_reset = _calculate_next_reset(reset_period)
|
|
||||||
|
|
||||||
# Upsert usage
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO user_feature_usage (profile_id, feature_id, usage_count, reset_at)
|
|
||||||
VALUES (%s, %s, 1, %s)
|
|
||||||
ON CONFLICT (profile_id, feature_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
usage_count = user_feature_usage.usage_count + 1,
|
|
||||||
updated = CURRENT_TIMESTAMP
|
|
||||||
""", (profile_id, feature_id, next_reset))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_next_reset(reset_period: str) -> Optional[datetime]:
|
|
||||||
"""
|
|
||||||
Calculate next reset timestamp based on reset period.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
reset_period: 'never', 'daily', 'monthly'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
datetime or None (for 'never')
|
|
||||||
"""
|
|
||||||
if reset_period == 'never':
|
|
||||||
return None
|
|
||||||
elif reset_period == 'daily':
|
|
||||||
# Reset at midnight
|
|
||||||
tomorrow = datetime.now().date() + timedelta(days=1)
|
|
||||||
return datetime.combine(tomorrow, datetime.min.time())
|
|
||||||
elif reset_period == 'monthly':
|
|
||||||
# Reset at start of next month
|
|
||||||
now = datetime.now()
|
|
||||||
if now.month == 12:
|
|
||||||
return datetime(now.year + 1, 1, 1)
|
|
||||||
else:
|
|
||||||
return datetime(now.year, now.month + 1, 1)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"""
|
|
||||||
Calculation Engine for Phase 0b - Goal-Aware Placeholders
|
|
||||||
|
|
||||||
This package contains all metric calculation functions for:
|
|
||||||
- Body metrics (K1-K5 from visualization concept)
|
|
||||||
- Nutrition metrics (E1-E5)
|
|
||||||
- Activity metrics (A1-A8)
|
|
||||||
- Recovery metrics (S1)
|
|
||||||
- Correlations (C1-C7)
|
|
||||||
- Scores (Goal Progress Score with Dynamic Focus Areas)
|
|
||||||
|
|
||||||
All calculations are designed to work with Dynamic Focus Areas v2.0.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .body_metrics import *
|
|
||||||
from .nutrition_metrics import *
|
|
||||||
from .activity_metrics import *
|
|
||||||
from .recovery_metrics import *
|
|
||||||
from .correlation_metrics import *
|
|
||||||
from .scores import *
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Body
|
|
||||||
'calculate_weight_7d_median',
|
|
||||||
'calculate_weight_28d_slope',
|
|
||||||
'calculate_fm_28d_change',
|
|
||||||
'calculate_lbm_28d_change',
|
|
||||||
'calculate_body_progress_score',
|
|
||||||
|
|
||||||
# Nutrition
|
|
||||||
'calculate_energy_balance_7d',
|
|
||||||
'calculate_protein_g_per_kg',
|
|
||||||
'calculate_nutrition_score',
|
|
||||||
|
|
||||||
# Activity
|
|
||||||
'calculate_training_minutes_week',
|
|
||||||
'calculate_activity_score',
|
|
||||||
|
|
||||||
# Recovery
|
|
||||||
'calculate_recovery_score_v2',
|
|
||||||
|
|
||||||
# Correlations
|
|
||||||
'calculate_lag_correlation',
|
|
||||||
|
|
||||||
# Meta Scores
|
|
||||||
'calculate_goal_progress_score',
|
|
||||||
'calculate_data_quality_score',
|
|
||||||
]
|
|
||||||
|
|
@ -1,646 +0,0 @@
|
||||||
"""
|
|
||||||
Activity Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements A1-A8 from visualization concept:
|
|
||||||
- A1: Training volume per week
|
|
||||||
- A2: Intensity distribution
|
|
||||||
- A3: Training quality matrix
|
|
||||||
- A4: Ability balance radar
|
|
||||||
- A5: Load monitoring (proxy-based)
|
|
||||||
- A6: Activity goal alignment score
|
|
||||||
- A7: Rest day compliance
|
|
||||||
- A8: VO2max development
|
|
||||||
|
|
||||||
All calculations work with training_types abilities system.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, List
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A1: Training Volume Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_training_minutes_week(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate total training minutes last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT SUM(duration_min) as total_minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return int(row['total_minutes']) if row and row['total_minutes'] else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate number of training sessions last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as session_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return int(row['session_count']) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate percentage of quality sessions (good or better) last 28 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row['total'] == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
pct = (row['quality_count'] / row['total']) * 100
|
|
||||||
return int(pct)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A2: Intensity Distribution (Proxy-based)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_intensity_proxy_distribution(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate intensity distribution (proxy until HR zones available)
|
|
||||||
Returns dict: {'low': X, 'moderate': Y, 'high': Z} in minutes
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_min, hr_avg, hr_max
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
low_min = 0
|
|
||||||
moderate_min = 0
|
|
||||||
high_min = 0
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration_min']
|
|
||||||
avg_hr = activity['hr_avg']
|
|
||||||
max_hr = activity['hr_max']
|
|
||||||
|
|
||||||
# Simple proxy classification
|
|
||||||
if avg_hr:
|
|
||||||
# Rough HR-based classification (assumes max HR ~190)
|
|
||||||
if avg_hr < 120:
|
|
||||||
low_min += duration
|
|
||||||
elif avg_hr < 150:
|
|
||||||
moderate_min += duration
|
|
||||||
else:
|
|
||||||
high_min += duration
|
|
||||||
else:
|
|
||||||
# Fallback: assume moderate
|
|
||||||
moderate_min += duration
|
|
||||||
|
|
||||||
return {
|
|
||||||
'low': low_min,
|
|
||||||
'moderate': moderate_min,
|
|
||||||
'high': high_min
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A4: Ability Balance Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_ability_balance(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate ability balance from training_types.abilities
|
|
||||||
Returns dict with scores per ability dimension (0-100)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT a.duration_min, tt.abilities
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_types tt ON a.training_category = tt.category
|
|
||||||
WHERE a.profile_id = %s
|
|
||||||
AND a.date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND tt.abilities IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Accumulate ability load (duration × ability weight)
|
|
||||||
ability_loads = {
|
|
||||||
'strength': 0,
|
|
||||||
'endurance': 0,
|
|
||||||
'mental': 0,
|
|
||||||
'coordination': 0,
|
|
||||||
'mobility': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration_min']
|
|
||||||
abilities = activity['abilities'] # JSONB
|
|
||||||
|
|
||||||
if not abilities:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for ability, weight in abilities.items():
|
|
||||||
if ability in ability_loads:
|
|
||||||
ability_loads[ability] += duration * weight
|
|
||||||
|
|
||||||
# Normalize to 0-100 scale
|
|
||||||
max_load = max(ability_loads.values()) if ability_loads else 1
|
|
||||||
if max_load == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized = {
|
|
||||||
ability: int((load / max_load) * 100)
|
|
||||||
for ability, load in ability_loads.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_strength(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get strength ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['strength'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_endurance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get endurance ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['endurance'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_mental(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get mental ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['mental'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_coordination(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get coordination ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['coordination'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get mobility ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['mobility'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A5: Load Monitoring (Proxy-based)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate proxy internal load (last 7 days)
|
|
||||||
Formula: duration × intensity_factor × quality_factor
|
|
||||||
"""
|
|
||||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
|
||||||
quality_factors = {
|
|
||||||
'excellent': 1.15,
|
|
||||||
'very_good': 1.05,
|
|
||||||
'good': 1.0,
|
|
||||||
'acceptable': 0.9,
|
|
||||||
'poor': 0.75,
|
|
||||||
'excluded': 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_min, hr_avg, rpe
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
total_load = 0
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration_min']
|
|
||||||
avg_hr = activity['hr_avg']
|
|
||||||
# Map RPE to quality (rpe 8-10 = excellent, 6-7 = good, 4-5 = moderate, <4 = poor)
|
|
||||||
rpe = activity.get('rpe')
|
|
||||||
if rpe and rpe >= 8:
|
|
||||||
quality = 'excellent'
|
|
||||||
elif rpe and rpe >= 6:
|
|
||||||
quality = 'good'
|
|
||||||
elif rpe and rpe >= 4:
|
|
||||||
quality = 'moderate'
|
|
||||||
else:
|
|
||||||
quality = 'good' # default
|
|
||||||
|
|
||||||
# Determine intensity
|
|
||||||
if avg_hr:
|
|
||||||
if avg_hr < 120:
|
|
||||||
intensity = 'low'
|
|
||||||
elif avg_hr < 150:
|
|
||||||
intensity = 'moderate'
|
|
||||||
else:
|
|
||||||
intensity = 'high'
|
|
||||||
else:
|
|
||||||
intensity = 'moderate'
|
|
||||||
|
|
||||||
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
|
||||||
total_load += load
|
|
||||||
|
|
||||||
return int(total_load)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate training monotony (last 7 days)
|
|
||||||
Monotony = mean daily load / std dev daily load
|
|
||||||
Higher = more monotonous
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, SUM(duration_min) as daily_duration
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
daily_loads = [float(row['daily_duration']) for row in cur.fetchall() if row['daily_duration']]
|
|
||||||
|
|
||||||
if len(daily_loads) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mean_load = sum(daily_loads) / len(daily_loads)
|
|
||||||
std_dev = statistics.stdev(daily_loads)
|
|
||||||
|
|
||||||
if std_dev == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monotony = mean_load / std_dev
|
|
||||||
return round(monotony, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_strain_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate training strain (last 7 days)
|
|
||||||
Strain = weekly load × monotony
|
|
||||||
"""
|
|
||||||
weekly_load = calculate_proxy_internal_load_7d(profile_id)
|
|
||||||
monotony = calculate_monotony_score(profile_id)
|
|
||||||
|
|
||||||
if weekly_load is None or monotony is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
strain = weekly_load * monotony
|
|
||||||
return int(strain)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A6: Activity Goal Alignment Score (Dynamic Focus Areas)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Activity goal alignment score 0-100
|
|
||||||
Weighted by user's activity-related focus areas
|
|
||||||
"""
|
|
||||||
if focus_weights is None:
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
# Activity-related focus areas (English keys from DB)
|
|
||||||
# Strength training
|
|
||||||
strength = focus_weights.get('strength', 0)
|
|
||||||
strength_endurance = focus_weights.get('strength_endurance', 0)
|
|
||||||
power = focus_weights.get('power', 0)
|
|
||||||
total_strength = strength + strength_endurance + power
|
|
||||||
|
|
||||||
# Endurance training
|
|
||||||
aerobic = focus_weights.get('aerobic_endurance', 0)
|
|
||||||
anaerobic = focus_weights.get('anaerobic_endurance', 0)
|
|
||||||
cardiovascular = focus_weights.get('cardiovascular_health', 0)
|
|
||||||
total_cardio = aerobic + anaerobic + cardiovascular
|
|
||||||
|
|
||||||
# Mobility/Coordination
|
|
||||||
flexibility = focus_weights.get('flexibility', 0)
|
|
||||||
mobility = focus_weights.get('mobility', 0)
|
|
||||||
balance = focus_weights.get('balance', 0)
|
|
||||||
reaction = focus_weights.get('reaction', 0)
|
|
||||||
rhythm = focus_weights.get('rhythm', 0)
|
|
||||||
coordination = focus_weights.get('coordination', 0)
|
|
||||||
total_ability = flexibility + mobility + balance + reaction + rhythm + coordination
|
|
||||||
|
|
||||||
total_activity_weight = total_strength + total_cardio + total_ability
|
|
||||||
|
|
||||||
if total_activity_weight == 0:
|
|
||||||
return None # No activity goals
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Weekly minutes (general activity volume)
|
|
||||||
minutes = calculate_training_minutes_week(profile_id)
|
|
||||||
if minutes is not None:
|
|
||||||
# WHO: 150-300 min/week
|
|
||||||
if 150 <= minutes <= 300:
|
|
||||||
minutes_score = 100
|
|
||||||
elif minutes < 150:
|
|
||||||
minutes_score = max(40, (minutes / 150) * 100)
|
|
||||||
else:
|
|
||||||
minutes_score = max(80, 100 - ((minutes - 300) / 10))
|
|
||||||
|
|
||||||
# Volume relevant for all activity types (20% base weight)
|
|
||||||
components.append(('minutes', minutes_score, total_activity_weight * 0.2))
|
|
||||||
|
|
||||||
# 2. Quality sessions (always relevant)
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id)
|
|
||||||
if quality_pct is not None:
|
|
||||||
# Quality gets 10% base weight
|
|
||||||
components.append(('quality', quality_pct, total_activity_weight * 0.1))
|
|
||||||
|
|
||||||
# 3. Strength presence (if strength focus active)
|
|
||||||
if total_strength > 0:
|
|
||||||
strength_score = _score_strength_presence(profile_id)
|
|
||||||
if strength_score is not None:
|
|
||||||
components.append(('strength', strength_score, total_strength))
|
|
||||||
|
|
||||||
# 4. Cardio presence (if cardio focus active)
|
|
||||||
if total_cardio > 0:
|
|
||||||
cardio_score = _score_cardio_presence(profile_id)
|
|
||||||
if cardio_score is not None:
|
|
||||||
components.append(('cardio', cardio_score, total_cardio))
|
|
||||||
|
|
||||||
# 5. Ability balance (if mobility/coordination focus active)
|
|
||||||
if total_ability > 0:
|
|
||||||
balance_score = _score_ability_balance(profile_id)
|
|
||||||
if balance_score is not None:
|
|
||||||
components.append(('balance', balance_score, total_ability))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_strength_presence(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score strength training presence (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(DISTINCT date) as strength_days
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND training_category = 'strength'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
strength_days = row['strength_days']
|
|
||||||
|
|
||||||
# Target: 2-4 days/week
|
|
||||||
if 2 <= strength_days <= 4:
|
|
||||||
return 100
|
|
||||||
elif strength_days == 1:
|
|
||||||
return 60
|
|
||||||
elif strength_days == 5:
|
|
||||||
return 85
|
|
||||||
elif strength_days == 0:
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
return 70
|
|
||||||
|
|
||||||
|
|
||||||
def _score_cardio_presence(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score cardio training presence (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(DISTINCT date) as cardio_days, SUM(duration_min) as cardio_minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND training_category = 'cardio'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cardio_days = row['cardio_days']
|
|
||||||
cardio_minutes = row['cardio_minutes'] or 0
|
|
||||||
|
|
||||||
# Target: 3-5 days/week, 150+ minutes
|
|
||||||
day_score = min(100, (cardio_days / 4) * 100)
|
|
||||||
minute_score = min(100, (cardio_minutes / 150) * 100)
|
|
||||||
|
|
||||||
return int((day_score + minute_score) / 2)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_ability_balance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score ability balance (0-100)"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
|
|
||||||
if not balance:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Good balance = all abilities > 40, std_dev < 30
|
|
||||||
values = list(balance.values())
|
|
||||||
min_value = min(values)
|
|
||||||
std_dev = statistics.stdev(values) if len(values) > 1 else 0
|
|
||||||
|
|
||||||
# Score based on minimum coverage and balance
|
|
||||||
min_score = min(100, min_value * 2) # Want all > 50
|
|
||||||
balance_score = max(0, 100 - (std_dev * 2)) # Want low std_dev
|
|
||||||
|
|
||||||
return int((min_score + balance_score) / 2)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A7: Rest Day Compliance
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_rest_day_compliance(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate rest day compliance percentage (last 28 days)
|
|
||||||
Returns percentage of planned rest days that were respected
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get planned rest days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, rest_config->>'focus' as rest_type
|
|
||||||
FROM rest_days
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
rest_days = {row['date']: row['rest_type'] for row in cur.fetchall()}
|
|
||||||
|
|
||||||
if not rest_days:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if training occurred on rest days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, training_category
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
training_days = {}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
if row['date'] not in training_days:
|
|
||||||
training_days[row['date']] = []
|
|
||||||
training_days[row['date']].append(row['training_category'])
|
|
||||||
|
|
||||||
# Count compliance
|
|
||||||
compliant = 0
|
|
||||||
total = len(rest_days)
|
|
||||||
|
|
||||||
for rest_date, rest_type in rest_days.items():
|
|
||||||
if rest_date not in training_days:
|
|
||||||
# Full rest = compliant
|
|
||||||
compliant += 1
|
|
||||||
else:
|
|
||||||
# Check if training violates rest type
|
|
||||||
categories = training_days[rest_date]
|
|
||||||
if rest_type == 'strength_rest' and 'strength' not in categories:
|
|
||||||
compliant += 1
|
|
||||||
elif rest_type == 'cardio_rest' and 'cardio' not in categories:
|
|
||||||
compliant += 1
|
|
||||||
# If rest_type == 'recovery', any training = non-compliant
|
|
||||||
|
|
||||||
compliance_pct = (compliant / total) * 100
|
|
||||||
return int(compliance_pct)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A8: VO2max Development
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_vo2max_trend_28d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate VO2max trend (change over 28 days)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT vo2_max, date
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND vo2_max IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
measurements = cur.fetchall()
|
|
||||||
|
|
||||||
if len(measurements) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = measurements[0]['vo2_max']
|
|
||||||
oldest = measurements[-1]['vo2_max']
|
|
||||||
|
|
||||||
change = recent - oldest
|
|
||||||
return round(change, 1)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for activity metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Activity entries last 28 days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as total,
|
|
||||||
COUNT(hr_avg) as with_hr,
|
|
||||||
COUNT(rpe) as with_quality
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
counts = cur.fetchone()
|
|
||||||
|
|
||||||
total_entries = counts['total']
|
|
||||||
hr_coverage = counts['with_hr'] / total_entries if total_entries > 0 else 0
|
|
||||||
quality_coverage = counts['with_quality'] / total_entries if total_entries > 0 else 0
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
frequency_score = min(100, (total_entries / 15) * 100) # 15 = ~4 sessions/week
|
|
||||||
hr_score = hr_coverage * 100
|
|
||||||
quality_score = quality_coverage * 100
|
|
||||||
|
|
||||||
# Overall score
|
|
||||||
overall_score = int(
|
|
||||||
frequency_score * 0.5 +
|
|
||||||
hr_score * 0.25 +
|
|
||||||
quality_score * 0.25
|
|
||||||
)
|
|
||||||
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"activities_28d": total_entries,
|
|
||||||
"hr_coverage_pct": int(hr_coverage * 100),
|
|
||||||
"quality_coverage_pct": int(quality_coverage * 100)
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"frequency": int(frequency_score),
|
|
||||||
"hr": int(hr_score),
|
|
||||||
"quality": int(quality_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,575 +0,0 @@
|
||||||
"""
|
|
||||||
Body Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements K1-K5 from visualization concept:
|
|
||||||
- K1: Weight trend + goal projection
|
|
||||||
- K2: Weight/FM/LBM multi-line chart
|
|
||||||
- K3: Circumference panel
|
|
||||||
- K4: Recomposition detector
|
|
||||||
- K5: Body progress score (goal-mode dependent)
|
|
||||||
|
|
||||||
All calculations include data quality/confidence assessment.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, Tuple
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K1: Weight Trend Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_weight_7d_median(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 7-day median weight (reduces daily noise)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
weights = [row['weight'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(weights) < 4: # Need at least 4 measurements
|
|
||||||
return None
|
|
||||||
|
|
||||||
return round(statistics.median(weights), 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_weight_28d_slope(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day weight slope (kg/day)"""
|
|
||||||
return _calculate_weight_slope(profile_id, days=28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_weight_90d_slope(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 90-day weight slope (kg/day)"""
|
|
||||||
return _calculate_weight_slope(profile_id, days=90)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate weight slope using linear regression
|
|
||||||
Returns kg/day (negative = weight loss)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
data = [(row['date'], row['weight']) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Need minimum data points based on period
|
|
||||||
min_points = max(18, int(days * 0.6)) # 60% coverage
|
|
||||||
if len(data) < min_points:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Convert dates to days since start
|
|
||||||
start_date = data[0][0]
|
|
||||||
x_values = [(date - start_date).days for date, _ in data]
|
|
||||||
y_values = [weight for _, weight in data]
|
|
||||||
|
|
||||||
# Linear regression
|
|
||||||
n = len(data)
|
|
||||||
x_mean = sum(x_values) / n
|
|
||||||
y_mean = sum(y_values) / n
|
|
||||||
|
|
||||||
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
|
||||||
denominator = sum((x - x_mean) ** 2 for x in x_values)
|
|
||||||
|
|
||||||
if denominator == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
slope = numerator / denominator
|
|
||||||
return round(slope, 4) # kg/day
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Calculate projected date to reach goal based on 28d trend
|
|
||||||
Returns ISO date string or None if unrealistic
|
|
||||||
"""
|
|
||||||
from goal_utils import get_goal_by_id
|
|
||||||
|
|
||||||
goal = get_goal_by_id(goal_id)
|
|
||||||
if not goal or goal['goal_type'] != 'weight':
|
|
||||||
return None
|
|
||||||
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
if not slope or slope == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
current = goal['current_value']
|
|
||||||
target = goal['target_value']
|
|
||||||
remaining = target - current
|
|
||||||
|
|
||||||
days_needed = remaining / slope
|
|
||||||
|
|
||||||
# Unrealistic if >2 years or negative
|
|
||||||
if days_needed < 0 or days_needed > 730:
|
|
||||||
return None
|
|
||||||
|
|
||||||
projection_date = datetime.now().date() + timedelta(days=int(days_needed))
|
|
||||||
return projection_date.isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_goal_progress_pct(current: float, target: float, start: float) -> int:
|
|
||||||
"""
|
|
||||||
Calculate goal progress percentage
|
|
||||||
Returns 0-100 (can exceed 100 if target surpassed)
|
|
||||||
"""
|
|
||||||
if start == target:
|
|
||||||
return 100 if current == target else 0
|
|
||||||
|
|
||||||
progress = ((current - start) / (target - start)) * 100
|
|
||||||
return max(0, min(100, int(progress)))
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K2: Fat Mass / Lean Mass Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_fm_28d_change(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day fat mass change (kg)"""
|
|
||||||
return _calculate_body_composition_change(profile_id, 'fm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_lbm_28d_change(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day lean body mass change (kg)"""
|
|
||||||
return _calculate_body_composition_change(profile_id, 'lbm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_body_composition_change(profile_id: str, metric: str, days: int) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate change in body composition over period
|
|
||||||
metric: 'fm' (fat mass) or 'lbm' (lean mass)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get weight and caliper measurements
|
|
||||||
cur.execute("""
|
|
||||||
SELECT w.date, w.weight, c.body_fat_pct
|
|
||||||
FROM weight_log w
|
|
||||||
LEFT JOIN caliper_log c ON w.profile_id = c.profile_id
|
|
||||||
AND w.date = c.date
|
|
||||||
WHERE w.profile_id = %s
|
|
||||||
AND w.date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
ORDER BY w.date DESC
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
data = [
|
|
||||||
{
|
|
||||||
'date': row['date'],
|
|
||||||
'weight': row['weight'],
|
|
||||||
'bf_pct': row['body_fat_pct']
|
|
||||||
}
|
|
||||||
for row in cur.fetchall()
|
|
||||||
if row['body_fat_pct'] is not None # Need BF% for composition
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(data) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Most recent and oldest measurement
|
|
||||||
recent = data[0]
|
|
||||||
oldest = data[-1]
|
|
||||||
|
|
||||||
# Calculate FM and LBM
|
|
||||||
recent_fm = recent['weight'] * (recent['bf_pct'] / 100)
|
|
||||||
recent_lbm = recent['weight'] - recent_fm
|
|
||||||
|
|
||||||
oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100)
|
|
||||||
oldest_lbm = oldest['weight'] - oldest_fm
|
|
||||||
|
|
||||||
if metric == 'fm':
|
|
||||||
change = recent_fm - oldest_fm
|
|
||||||
else: # lbm
|
|
||||||
change = recent_lbm - oldest_lbm
|
|
||||||
|
|
||||||
return round(change, 2)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K3: Circumference Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_waist_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day waist circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_waist', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_hip_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day hip circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_hip', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day chest circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_chest', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day arm circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day thigh circumference change (cm)"""
|
|
||||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
|
||||||
|
|
||||||
if delta is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return round(delta, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_circumference_delta(profile_id: str, column: str, days: int) -> Optional[float]:
|
|
||||||
"""Calculate change in circumference measurement"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT {column}
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
AND {column} IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
recent = cur.fetchone()
|
|
||||||
if not recent:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT {column}
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
AND {column} IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
oldest = cur.fetchone()
|
|
||||||
if not oldest:
|
|
||||||
return None
|
|
||||||
|
|
||||||
change = recent[column] - oldest[column]
|
|
||||||
return round(change, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_waist_hip_ratio(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate current waist-to-hip ratio"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c_waist, c_hip
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND c_waist IS NOT NULL
|
|
||||||
AND c_hip IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ratio = row['c_waist'] / row['c_hip']
|
|
||||||
return round(ratio, 3)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K4: Recomposition Detector
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_recomposition_quadrant(profile_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Determine recomposition quadrant based on 28d changes:
|
|
||||||
- optimal: FM down, LBM up
|
|
||||||
- cut_with_risk: FM down, LBM down
|
|
||||||
- bulk: FM up, LBM up
|
|
||||||
- unfavorable: FM up, LBM down
|
|
||||||
"""
|
|
||||||
fm_change = calculate_fm_28d_change(profile_id)
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
|
|
||||||
if fm_change is None or lbm_change is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if fm_change < 0 and lbm_change > 0:
|
|
||||||
return "optimal"
|
|
||||||
elif fm_change < 0 and lbm_change < 0:
|
|
||||||
return "cut_with_risk"
|
|
||||||
elif fm_change > 0 and lbm_change > 0:
|
|
||||||
return "bulk"
|
|
||||||
else: # fm_change > 0 and lbm_change < 0
|
|
||||||
return "unfavorable"
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# K5: Body Progress Score (Dynamic Focus Areas)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate body progress score (0-100) weighted by user's focus areas
|
|
||||||
|
|
||||||
Components:
|
|
||||||
- Weight trend alignment with goals
|
|
||||||
- FM/LBM changes (recomposition quality)
|
|
||||||
- Circumference changes (especially waist)
|
|
||||||
- Goal progress percentage
|
|
||||||
|
|
||||||
Weighted dynamically based on user's focus area priorities
|
|
||||||
"""
|
|
||||||
if focus_weights is None:
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
# Get all body-related focus area weights (English keys from DB)
|
|
||||||
weight_loss = focus_weights.get('weight_loss', 0)
|
|
||||||
muscle_gain = focus_weights.get('muscle_gain', 0)
|
|
||||||
body_recomp = focus_weights.get('body_recomposition', 0)
|
|
||||||
|
|
||||||
total_body_weight = weight_loss + muscle_gain + body_recomp
|
|
||||||
|
|
||||||
if total_body_weight == 0:
|
|
||||||
return None # No body-related goals
|
|
||||||
|
|
||||||
# Calculate component scores (0-100)
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# Weight trend component (if weight loss goal active)
|
|
||||||
if weight_loss > 0:
|
|
||||||
weight_score = _score_weight_trend(profile_id)
|
|
||||||
if weight_score is not None:
|
|
||||||
components.append(('weight', weight_score, weight_loss))
|
|
||||||
|
|
||||||
# Body composition component (if muscle gain or recomp goal active)
|
|
||||||
if muscle_gain > 0 or body_recomp > 0:
|
|
||||||
comp_score = _score_body_composition(profile_id)
|
|
||||||
if comp_score is not None:
|
|
||||||
components.append(('composition', comp_score, muscle_gain + body_recomp))
|
|
||||||
|
|
||||||
# Waist circumference component (proxy for health)
|
|
||||||
waist_score = _score_waist_trend(profile_id)
|
|
||||||
if waist_score is not None:
|
|
||||||
# Waist gets 20% base weight + bonus from weight loss goals
|
|
||||||
waist_weight = 20 + (weight_loss * 0.3)
|
|
||||||
components.append(('waist', waist_score, waist_weight))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_weight_trend(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score weight trend alignment with goals (0-100)"""
|
|
||||||
from goal_utils import get_active_goals
|
|
||||||
|
|
||||||
goals = get_active_goals(profile_id)
|
|
||||||
weight_goals = [g for g in goals if g.get('goal_type') == 'weight']
|
|
||||||
if not weight_goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Use primary or first active goal
|
|
||||||
goal = next((g for g in weight_goals if g.get('is_primary')), weight_goals[0])
|
|
||||||
|
|
||||||
current = goal.get('current_value')
|
|
||||||
target = goal.get('target_value')
|
|
||||||
start = goal.get('start_value')
|
|
||||||
|
|
||||||
if None in [current, target]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Convert Decimal to float (PostgreSQL NUMERIC returns Decimal)
|
|
||||||
current = float(current)
|
|
||||||
target = float(target)
|
|
||||||
|
|
||||||
# If no start_value, use oldest weight in last 90 days
|
|
||||||
if start is None:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '90 days'
|
|
||||||
ORDER BY date ASC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
start = float(row['weight']) if row else current
|
|
||||||
else:
|
|
||||||
start = float(start)
|
|
||||||
|
|
||||||
# Progress percentage
|
|
||||||
progress_pct = calculate_goal_progress_pct(current, target, start)
|
|
||||||
|
|
||||||
# Bonus/penalty based on trend
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
if slope is not None:
|
|
||||||
desired_direction = -1 if target < start else 1
|
|
||||||
actual_direction = -1 if slope < 0 else 1
|
|
||||||
|
|
||||||
if desired_direction == actual_direction:
|
|
||||||
# Moving in right direction
|
|
||||||
score = min(100, progress_pct + 10)
|
|
||||||
else:
|
|
||||||
# Moving in wrong direction
|
|
||||||
score = max(0, progress_pct - 20)
|
|
||||||
else:
|
|
||||||
score = progress_pct
|
|
||||||
|
|
||||||
return int(score)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_body_composition(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score body composition changes (0-100)"""
|
|
||||||
fm_change = calculate_fm_28d_change(profile_id)
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
|
|
||||||
if fm_change is None or lbm_change is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
quadrant = calculate_recomposition_quadrant(profile_id)
|
|
||||||
|
|
||||||
# Scoring by quadrant
|
|
||||||
if quadrant == "optimal":
|
|
||||||
return 100
|
|
||||||
elif quadrant == "cut_with_risk":
|
|
||||||
# Penalty proportional to LBM loss
|
|
||||||
penalty = min(30, abs(lbm_change) * 15)
|
|
||||||
return max(50, 80 - int(penalty))
|
|
||||||
elif quadrant == "bulk":
|
|
||||||
# Score based on FM/LBM ratio
|
|
||||||
if lbm_change > 0 and fm_change > 0:
|
|
||||||
ratio = lbm_change / fm_change
|
|
||||||
if ratio >= 3: # 3:1 LBM:FM = excellent bulk
|
|
||||||
return 90
|
|
||||||
elif ratio >= 2:
|
|
||||||
return 75
|
|
||||||
elif ratio >= 1:
|
|
||||||
return 60
|
|
||||||
else:
|
|
||||||
return 45
|
|
||||||
return 60
|
|
||||||
else: # unfavorable
|
|
||||||
return 20
|
|
||||||
|
|
||||||
|
|
||||||
def _score_waist_trend(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score waist circumference trend (0-100)"""
|
|
||||||
delta = calculate_waist_28d_delta(profile_id)
|
|
||||||
|
|
||||||
if delta is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Waist reduction is almost always positive
|
|
||||||
if delta <= -3: # >3cm reduction
|
|
||||||
return 100
|
|
||||||
elif delta <= -2:
|
|
||||||
return 90
|
|
||||||
elif delta <= -1:
|
|
||||||
return 80
|
|
||||||
elif delta <= 0:
|
|
||||||
return 70
|
|
||||||
elif delta <= 1:
|
|
||||||
return 55
|
|
||||||
elif delta <= 2:
|
|
||||||
return 40
|
|
||||||
else: # >2cm increase
|
|
||||||
return 20
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_body_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for body metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Weight measurement frequency (last 28 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
weight_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
# Caliper measurement frequency (last 28 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
caliper_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
# Circumference measurement frequency (last 28 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
circ_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
weight_score = min(100, (weight_count / 18) * 100) # 18 = ~65% of 28 days
|
|
||||||
caliper_score = min(100, (caliper_count / 4) * 100) # 4 = weekly
|
|
||||||
circ_score = min(100, (circ_count / 4) * 100)
|
|
||||||
|
|
||||||
# Overall score (weight 50%, caliper 30%, circ 20%)
|
|
||||||
overall_score = int(
|
|
||||||
weight_score * 0.5 +
|
|
||||||
caliper_score * 0.3 +
|
|
||||||
circ_score * 0.2
|
|
||||||
)
|
|
||||||
|
|
||||||
# Confidence level
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"weight_28d": weight_count,
|
|
||||||
"caliper_28d": caliper_count,
|
|
||||||
"circumference_28d": circ_count
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"weight": int(weight_score),
|
|
||||||
"caliper": int(caliper_score),
|
|
||||||
"circumference": int(circ_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,508 +0,0 @@
|
||||||
"""
|
|
||||||
Correlation Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements C1-C7 from visualization concept:
|
|
||||||
- C1: Energy balance vs. weight change (lagged)
|
|
||||||
- C2: Protein adequacy vs. LBM trend
|
|
||||||
- C3: Training load vs. HRV/RHR (1-3 days delayed)
|
|
||||||
- C4: Sleep duration + regularity vs. recovery
|
|
||||||
- C5: Blood pressure context matrix
|
|
||||||
- C6: Plateau detector
|
|
||||||
- C7: Multi-factor driver panel
|
|
||||||
|
|
||||||
All correlations are clearly marked as exploratory and include:
|
|
||||||
- Effect size
|
|
||||||
- Best lag window
|
|
||||||
- Data point count
|
|
||||||
- Confidence level
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, List, Tuple
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C1: Energy Balance vs. Weight Change (Lagged)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate lagged correlation between two variables
|
|
||||||
|
|
||||||
Args:
|
|
||||||
var1: 'energy', 'protein', 'training_load'
|
|
||||||
var2: 'weight', 'lbm', 'hrv', 'rhr'
|
|
||||||
max_lag_days: Maximum lag to test
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'best_lag': X, # days
|
|
||||||
'correlation': 0.XX, # -1 to 1
|
|
||||||
'direction': 'positive'/'negative'/'none',
|
|
||||||
'confidence': 'high'/'medium'/'low',
|
|
||||||
'data_points': N
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if var1 == 'energy' and var2 == 'weight':
|
|
||||||
return _correlate_energy_weight(profile_id, max_lag_days)
|
|
||||||
elif var1 == 'protein' and var2 == 'lbm':
|
|
||||||
return _correlate_protein_lbm(profile_id, max_lag_days)
|
|
||||||
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
|
|
||||||
return _correlate_load_vitals(profile_id, var2, max_lag_days)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate energy balance with weight change
|
|
||||||
Test lags: 0, 3, 7, 10, 14 days
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get energy balance data (daily calories - estimated TDEE)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT n.date, n.kcal, w.weight
|
|
||||||
FROM nutrition_log n
|
|
||||||
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
|
|
||||||
AND w.date = n.date
|
|
||||||
WHERE n.profile_id = %s
|
|
||||||
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
|
|
||||||
ORDER BY n.date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(data) < 30:
|
|
||||||
return {
|
|
||||||
'best_lag': None,
|
|
||||||
'correlation': None,
|
|
||||||
'direction': 'none',
|
|
||||||
'confidence': 'low',
|
|
||||||
'data_points': len(data),
|
|
||||||
'reason': 'Insufficient data (<30 days)'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate 7d rolling energy balance
|
|
||||||
# (Simplified - actual implementation would need TDEE estimation)
|
|
||||||
|
|
||||||
# For now, return placeholder
|
|
||||||
return {
|
|
||||||
'best_lag': 7,
|
|
||||||
'correlation': -0.45, # Placeholder
|
|
||||||
'direction': 'negative', # Higher deficit = lower weight (expected)
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': len(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""Correlate protein intake with LBM trend"""
|
|
||||||
# TODO: Implement full correlation calculation
|
|
||||||
return {
|
|
||||||
'best_lag': 0,
|
|
||||||
'correlation': 0.32, # Placeholder
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 28
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate training load with HRV or RHR
|
|
||||||
Test lags: 1, 2, 3 days
|
|
||||||
"""
|
|
||||||
# TODO: Implement full correlation calculation
|
|
||||||
if vital == 'hrv':
|
|
||||||
return {
|
|
||||||
'best_lag': 1,
|
|
||||||
'correlation': -0.38, # Negative = high load reduces HRV (expected)
|
|
||||||
'direction': 'negative',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 25
|
|
||||||
}
|
|
||||||
else: # rhr
|
|
||||||
return {
|
|
||||||
'best_lag': 1,
|
|
||||||
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 25
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C4: Sleep vs. Recovery Correlation
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_correlation_sleep_recovery(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate sleep quality/duration with recovery score
|
|
||||||
"""
|
|
||||||
# TODO: Implement full correlation
|
|
||||||
return {
|
|
||||||
'correlation': 0.65, # Strong positive (expected)
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'high',
|
|
||||||
'data_points': 28
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C6: Plateau Detector
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_plateau_detected(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Detect if user is in a plateau based on goal mode
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'plateau_detected': True/False,
|
|
||||||
'plateau_type': 'weight_loss'/'strength'/'endurance'/None,
|
|
||||||
'confidence': 'high'/'medium'/'low',
|
|
||||||
'duration_days': X,
|
|
||||||
'top_factors': [list of potential causes]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Determine primary focus area
|
|
||||||
top_focus = max(focus_weights, key=focus_weights.get)
|
|
||||||
|
|
||||||
# Check for plateau based on focus area
|
|
||||||
if top_focus in ['körpergewicht', 'körperfett']:
|
|
||||||
return _detect_weight_plateau(profile_id)
|
|
||||||
elif top_focus == 'kraftaufbau':
|
|
||||||
return _detect_strength_plateau(profile_id)
|
|
||||||
elif top_focus == 'cardio':
|
|
||||||
return _detect_endurance_plateau(profile_id)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_weight_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect weight loss plateau"""
|
|
||||||
from calculations.body_metrics import calculate_weight_28d_slope
|
|
||||||
from calculations.nutrition_metrics import calculate_nutrition_score
|
|
||||||
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
nutrition_score = calculate_nutrition_score(profile_id)
|
|
||||||
|
|
||||||
if slope is None:
|
|
||||||
return {'plateau_detected': False, 'reason': 'Insufficient data'}
|
|
||||||
|
|
||||||
# Plateau = flat weight for 28 days despite adherence
|
|
||||||
is_plateau = abs(slope) < 0.02 and nutrition_score and nutrition_score > 70
|
|
||||||
|
|
||||||
if is_plateau:
|
|
||||||
factors = []
|
|
||||||
|
|
||||||
# Check potential factors
|
|
||||||
if nutrition_score > 85:
|
|
||||||
factors.append('Hohe Adhärenz trotz Stagnation → mögliche Anpassung des Stoffwechsels')
|
|
||||||
|
|
||||||
# Check if deficit is too small
|
|
||||||
from calculations.nutrition_metrics import calculate_energy_balance_7d
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
if balance and balance > -200:
|
|
||||||
factors.append('Energiedefizit zu gering (<200 kcal/Tag)')
|
|
||||||
|
|
||||||
# Check water retention (if waist is shrinking but weight stable)
|
|
||||||
from calculations.body_metrics import calculate_waist_28d_delta
|
|
||||||
waist_delta = calculate_waist_28d_delta(profile_id)
|
|
||||||
if waist_delta and waist_delta < -1:
|
|
||||||
factors.append('Taillenumfang sinkt → mögliche Wasserretention maskiert Fettabbau')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plateau_detected': True,
|
|
||||||
'plateau_type': 'weight_loss',
|
|
||||||
'confidence': 'high' if len(factors) >= 2 else 'medium',
|
|
||||||
'duration_days': 28,
|
|
||||||
'top_factors': factors[:3]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {'plateau_detected': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_strength_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect strength training plateau"""
|
|
||||||
from calculations.body_metrics import calculate_lbm_28d_change
|
|
||||||
from calculations.activity_metrics import calculate_activity_score
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
activity_score = calculate_activity_score(profile_id)
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
|
|
||||||
if lbm_change is None:
|
|
||||||
return {'plateau_detected': False, 'reason': 'Insufficient data'}
|
|
||||||
|
|
||||||
# Plateau = flat LBM despite high activity score
|
|
||||||
is_plateau = abs(lbm_change) < 0.3 and activity_score and activity_score > 75
|
|
||||||
|
|
||||||
if is_plateau:
|
|
||||||
factors = []
|
|
||||||
|
|
||||||
if recovery_score and recovery_score < 60:
|
|
||||||
factors.append('Recovery Score niedrig → möglicherweise Übertraining')
|
|
||||||
|
|
||||||
from calculations.nutrition_metrics import calculate_protein_adequacy_28d
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score and protein_score < 70:
|
|
||||||
factors.append('Proteinzufuhr unter Zielbereich')
|
|
||||||
|
|
||||||
from calculations.activity_metrics import calculate_monotony_score
|
|
||||||
monotony = calculate_monotony_score(profile_id)
|
|
||||||
if monotony and monotony > 2.0:
|
|
||||||
factors.append('Hohe Trainingsmonotonie → Stimulus-Anpassung')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plateau_detected': True,
|
|
||||||
'plateau_type': 'strength',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'duration_days': 28,
|
|
||||||
'top_factors': factors[:3]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {'plateau_detected': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_endurance_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect endurance plateau"""
|
|
||||||
from calculations.activity_metrics import calculate_training_minutes_week, calculate_monotony_score
|
|
||||||
from calculations.recovery_metrics import calculate_vo2max_trend_28d
|
|
||||||
|
|
||||||
# TODO: Implement when vitals_baseline.vo2_max is populated
|
|
||||||
return {'plateau_detected': False, 'reason': 'VO2max tracking not yet implemented'}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C7: Multi-Factor Driver Panel
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_top_drivers(profile_id: str) -> Optional[List[Dict]]:
|
|
||||||
"""
|
|
||||||
Calculate top influencing factors for goal progress
|
|
||||||
|
|
||||||
Returns list of drivers:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'factor': 'Energiebilanz',
|
|
||||||
'status': 'förderlich'/'neutral'/'hinderlich',
|
|
||||||
'evidence': 'hoch'/'mittel'/'niedrig',
|
|
||||||
'reason': '1-sentence explanation'
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
drivers = []
|
|
||||||
|
|
||||||
# 1. Energy balance
|
|
||||||
from calculations.nutrition_metrics import calculate_energy_balance_7d
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
if balance is not None:
|
|
||||||
if -500 <= balance <= -200:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Moderates Defizit ({int(balance)} kcal/Tag) unterstützt Fettabbau'
|
|
||||||
elif balance < -800:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Sehr großes Defizit ({int(balance)} kcal/Tag) → Risiko für Magermasseverlust'
|
|
||||||
elif -200 < balance < 200:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = 'Energiebilanz ausgeglichen'
|
|
||||||
else:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Energieüberschuss ({int(balance)} kcal/Tag)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Energiebilanz',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. Protein adequacy
|
|
||||||
from calculations.nutrition_metrics import calculate_protein_adequacy_28d
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score is not None:
|
|
||||||
if protein_score >= 80:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Proteinzufuhr konstant im Zielbereich (Score: {protein_score})'
|
|
||||||
elif protein_score >= 60:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Proteinzufuhr teilweise im Zielbereich (Score: {protein_score})'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Proteinzufuhr häufig unter Zielbereich (Score: {protein_score})'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Proteinzufuhr',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 3. Sleep duration
|
|
||||||
from calculations.recovery_metrics import calculate_sleep_avg_duration_7d
|
|
||||||
sleep_hours = calculate_sleep_avg_duration_7d(profile_id)
|
|
||||||
if sleep_hours is not None:
|
|
||||||
if sleep_hours >= 7:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Schlafdauer ausreichend ({sleep_hours:.1f}h/Nacht)'
|
|
||||||
elif sleep_hours >= 6.5:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Schlafdauer knapp ausreichend ({sleep_hours:.1f}h/Nacht)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Schlafdauer zu gering ({sleep_hours:.1f}h/Nacht < 7h Empfehlung)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Schlafdauer',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 4. Sleep regularity
|
|
||||||
from calculations.recovery_metrics import calculate_sleep_regularity_proxy
|
|
||||||
regularity = calculate_sleep_regularity_proxy(profile_id)
|
|
||||||
if regularity is not None:
|
|
||||||
if regularity <= 45:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Schlafrhythmus regelmäßig (Abweichung: {int(regularity)} min)'
|
|
||||||
elif regularity <= 75:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Schlafrhythmus moderat variabel (Abweichung: {int(regularity)} min)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Schlafrhythmus stark variabel (Abweichung: {int(regularity)} min)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Schlafregelmäßigkeit',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 5. Training consistency
|
|
||||||
from calculations.activity_metrics import calculate_training_frequency_7d
|
|
||||||
frequency = calculate_training_frequency_7d(profile_id)
|
|
||||||
if frequency is not None:
|
|
||||||
if 3 <= frequency <= 6:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Trainingsfrequenz im Zielbereich ({frequency}× pro Woche)'
|
|
||||||
elif frequency <= 2:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Trainingsfrequenz zu niedrig ({frequency}× pro Woche)'
|
|
||||||
else:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Trainingsfrequenz sehr hoch ({frequency}× pro Woche) → Recovery beachten'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Trainingskonsistenz',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 6. Quality sessions
|
|
||||||
from calculations.activity_metrics import calculate_quality_sessions_pct
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id)
|
|
||||||
if quality_pct is not None:
|
|
||||||
if quality_pct >= 75:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'{quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
elif quality_pct >= 50:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'{quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Nur {quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Trainingsqualität',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 7. Recovery score
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
recovery = calculate_recovery_score_v2(profile_id)
|
|
||||||
if recovery is not None:
|
|
||||||
if recovery >= 70:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Recovery Score gut ({recovery}/100)'
|
|
||||||
elif recovery >= 50:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Recovery Score moderat ({recovery}/100)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Recovery Score niedrig ({recovery}/100) → mehr Erholung nötig'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Recovery',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 8. Rest day compliance
|
|
||||||
from calculations.activity_metrics import calculate_rest_day_compliance
|
|
||||||
compliance = calculate_rest_day_compliance(profile_id)
|
|
||||||
if compliance is not None:
|
|
||||||
if compliance >= 80:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Ruhetage gut eingehalten ({compliance}%)'
|
|
||||||
elif compliance >= 60:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Ruhetage teilweise eingehalten ({compliance}%)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Ruhetage häufig ignoriert ({compliance}%) → Übertrainingsrisiko'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Ruhetagsrespekt',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by importance: hinderlich first, then förderlich, then neutral
|
|
||||||
priority = {'hinderlich': 0, 'förderlich': 1, 'neutral': 2}
|
|
||||||
drivers.sort(key=lambda d: priority[d['status']])
|
|
||||||
|
|
||||||
return drivers[:8] # Top 8 drivers
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Confidence/Evidence Levels
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_correlation_confidence(data_points: int, correlation: float) -> str:
|
|
||||||
"""
|
|
||||||
Determine confidence level for correlation
|
|
||||||
|
|
||||||
Returns: 'high', 'medium', or 'low'
|
|
||||||
"""
|
|
||||||
# Need sufficient data points
|
|
||||||
if data_points < 20:
|
|
||||||
return 'low'
|
|
||||||
|
|
||||||
# Strong correlation with good data
|
|
||||||
if data_points >= 40 and abs(correlation) >= 0.5:
|
|
||||||
return 'high'
|
|
||||||
elif data_points >= 30 and abs(correlation) >= 0.4:
|
|
||||||
return 'medium'
|
|
||||||
else:
|
|
||||||
return 'low'
|
|
||||||
|
|
@ -1,641 +0,0 @@
|
||||||
"""
|
|
||||||
Nutrition Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements E1-E5 from visualization concept:
|
|
||||||
- E1: Energy balance vs. weight trend
|
|
||||||
- E2: Protein adequacy (g/kg)
|
|
||||||
- E3: Macro distribution & consistency
|
|
||||||
- E4: Nutrition adherence score
|
|
||||||
- E5: Energy availability warning (heuristic)
|
|
||||||
|
|
||||||
All calculations include data quality assessment.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict, List
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E1: Energy Balance Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate 7-day average energy balance (kcal/day)
|
|
||||||
Positive = surplus, Negative = deficit
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
calories = [row['kcal'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(calories) < 4: # Need at least 4 days
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_intake = float(sum(calories) / len(calories))
|
|
||||||
|
|
||||||
# Get estimated TDEE (simplified - could use Harris-Benedict)
|
|
||||||
# For now, use weight-based estimate
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
weight_row = cur.fetchone()
|
|
||||||
if not weight_row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Simple TDEE estimate: bodyweight (kg) × 30-35
|
|
||||||
# TODO: Improve with activity level, age, gender
|
|
||||||
estimated_tdee = float(weight_row['weight']) * 32.5
|
|
||||||
|
|
||||||
balance = avg_intake - estimated_tdee
|
|
||||||
|
|
||||||
return round(balance, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Classify energy balance as deficit/maintenance/surplus
|
|
||||||
Returns: 'deficit', 'maintenance', 'surplus', or None
|
|
||||||
"""
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
|
|
||||||
if balance is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if balance < -200:
|
|
||||||
return 'deficit'
|
|
||||||
elif balance > 200:
|
|
||||||
return 'surplus'
|
|
||||||
else:
|
|
||||||
return 'maintenance'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E2: Protein Adequacy Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_protein_g_per_kg(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate average protein intake in g/kg bodyweight (last 7 days)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent weight
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
weight_row = cur.fetchone()
|
|
||||||
if not weight_row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
weight = float(weight_row['weight'])
|
|
||||||
|
|
||||||
# Get protein intake
|
|
||||||
cur.execute("""
|
|
||||||
SELECT protein_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND protein_g IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
protein_values = [row['protein_g'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(protein_values) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_protein = float(sum(protein_values) / len(protein_values))
|
|
||||||
protein_per_kg = avg_protein / weight
|
|
||||||
|
|
||||||
return round(protein_per_kg, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_protein_days_in_target(profile_id: str, target_low: float = 1.6, target_high: float = 2.2) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Calculate how many days in last 7 were within protein target
|
|
||||||
Returns: "5/7" format or None
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent weight
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
weight_row = cur.fetchone()
|
|
||||||
if not weight_row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
weight = float(weight_row['weight'])
|
|
||||||
|
|
||||||
# Get protein intake last 7 days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT protein_g, date
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND protein_g IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
protein_data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(protein_data) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Count days in target range
|
|
||||||
days_in_target = 0
|
|
||||||
total_days = len(protein_data)
|
|
||||||
|
|
||||||
for row in protein_data:
|
|
||||||
protein_per_kg = float(row['protein_g']) / weight
|
|
||||||
if target_low <= protein_per_kg <= target_high:
|
|
||||||
days_in_target += 1
|
|
||||||
|
|
||||||
return f"{days_in_target}/{total_days}"
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Protein adequacy score 0-100 (last 28 days)
|
|
||||||
Based on consistency and target achievement
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get average weight (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(weight) as avg_weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
weight_row = cur.fetchone()
|
|
||||||
if not weight_row or not weight_row['avg_weight']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
weight = float(weight_row['avg_weight'])
|
|
||||||
|
|
||||||
# Get protein intake (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT protein_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND protein_g IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
protein_values = [float(row['protein_g']) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(protein_values) < 18: # 60% coverage
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate metrics
|
|
||||||
protein_per_kg_values = [p / weight for p in protein_values]
|
|
||||||
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
|
|
||||||
|
|
||||||
# Target range: 1.6-2.2 g/kg for active individuals
|
|
||||||
target_mid = 1.9
|
|
||||||
|
|
||||||
# Score based on distance from target
|
|
||||||
if 1.6 <= avg_protein_per_kg <= 2.2:
|
|
||||||
base_score = 100
|
|
||||||
elif avg_protein_per_kg < 1.6:
|
|
||||||
# Below target
|
|
||||||
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
|
|
||||||
else:
|
|
||||||
# Above target (less penalty)
|
|
||||||
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
|
|
||||||
|
|
||||||
# Consistency bonus/penalty
|
|
||||||
std_dev = statistics.stdev(protein_per_kg_values)
|
|
||||||
if std_dev < 0.3:
|
|
||||||
consistency_bonus = 10
|
|
||||||
elif std_dev < 0.5:
|
|
||||||
consistency_bonus = 0
|
|
||||||
else:
|
|
||||||
consistency_bonus = -10
|
|
||||||
|
|
||||||
final_score = min(100, max(0, base_score + consistency_bonus))
|
|
||||||
|
|
||||||
return int(final_score)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E3: Macro Distribution & Consistency
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Macro consistency score 0-100 (last 28 days)
|
|
||||||
Lower variability = higher score
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT kcal, protein_g, fat_g, carbs_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND kcal IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(data) < 18:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate coefficient of variation for each macro
|
|
||||||
def cv(values):
|
|
||||||
"""Coefficient of variation (std_dev / mean)"""
|
|
||||||
if not values or len(values) < 2:
|
|
||||||
return None
|
|
||||||
mean = sum(values) / len(values)
|
|
||||||
if mean == 0:
|
|
||||||
return None
|
|
||||||
std_dev = statistics.stdev(values)
|
|
||||||
return std_dev / mean
|
|
||||||
|
|
||||||
calories_cv = cv([d['kcal'] for d in data])
|
|
||||||
protein_cv = cv([d['protein_g'] for d in data if d['protein_g']])
|
|
||||||
fat_cv = cv([d['fat_g'] for d in data if d['fat_g']])
|
|
||||||
carbs_cv = cv([d['carbs_g'] for d in data if d['carbs_g']])
|
|
||||||
|
|
||||||
cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None]
|
|
||||||
|
|
||||||
if not cv_values:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_cv = sum(cv_values) / len(cv_values)
|
|
||||||
|
|
||||||
# Score: lower CV = higher score
|
|
||||||
# CV < 0.2 = excellent consistency
|
|
||||||
# CV > 0.5 = poor consistency
|
|
||||||
if avg_cv < 0.2:
|
|
||||||
score = 100
|
|
||||||
elif avg_cv < 0.3:
|
|
||||||
score = 85
|
|
||||||
elif avg_cv < 0.4:
|
|
||||||
score = 70
|
|
||||||
elif avg_cv < 0.5:
|
|
||||||
score = 55
|
|
||||||
else:
|
|
||||||
score = max(30, 100 - (avg_cv * 100))
|
|
||||||
|
|
||||||
return int(score)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_intake_volatility(profile_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Classify intake volatility: 'stable', 'moderate', 'high'
|
|
||||||
"""
|
|
||||||
consistency = calculate_macro_consistency_score(profile_id)
|
|
||||||
|
|
||||||
if consistency is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if consistency >= 80:
|
|
||||||
return 'stable'
|
|
||||||
elif consistency >= 60:
|
|
||||||
return 'moderate'
|
|
||||||
else:
|
|
||||||
return 'high'
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E4: Nutrition Adherence Score (Dynamic Focus Areas)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Nutrition adherence score 0-100
|
|
||||||
Weighted by user's nutrition-related focus areas
|
|
||||||
"""
|
|
||||||
if focus_weights is None:
|
|
||||||
from calculations.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
# Nutrition-related focus areas (English keys from DB)
|
|
||||||
protein_intake = focus_weights.get('protein_intake', 0)
|
|
||||||
calorie_balance = focus_weights.get('calorie_balance', 0)
|
|
||||||
macro_consistency = focus_weights.get('macro_consistency', 0)
|
|
||||||
meal_timing = focus_weights.get('meal_timing', 0)
|
|
||||||
hydration = focus_weights.get('hydration', 0)
|
|
||||||
|
|
||||||
total_nutrition_weight = protein_intake + calorie_balance + macro_consistency + meal_timing + hydration
|
|
||||||
|
|
||||||
if total_nutrition_weight == 0:
|
|
||||||
return None # No nutrition goals
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Calorie target adherence (if calorie_balance goal active)
|
|
||||||
if calorie_balance > 0:
|
|
||||||
calorie_score = _score_calorie_adherence(profile_id)
|
|
||||||
if calorie_score is not None:
|
|
||||||
components.append(('calories', calorie_score, calorie_balance))
|
|
||||||
|
|
||||||
# 2. Protein target adherence (if protein_intake goal active)
|
|
||||||
if protein_intake > 0:
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score is not None:
|
|
||||||
components.append(('protein', protein_score, protein_intake))
|
|
||||||
|
|
||||||
# 3. Intake consistency (if macro_consistency goal active)
|
|
||||||
if macro_consistency > 0:
|
|
||||||
consistency_score = calculate_macro_consistency_score(profile_id)
|
|
||||||
if consistency_score is not None:
|
|
||||||
components.append(('consistency', consistency_score, macro_consistency))
|
|
||||||
|
|
||||||
# 4. Macro balance (always relevant if any nutrition goal)
|
|
||||||
if total_nutrition_weight > 0:
|
|
||||||
macro_score = _score_macro_balance(profile_id)
|
|
||||||
if macro_score is not None:
|
|
||||||
# Use 20% of total weight for macro balance
|
|
||||||
components.append(('macros', macro_score, total_nutrition_weight * 0.2))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_calorie_adherence(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score calorie target adherence (0-100)"""
|
|
||||||
# Check for energy balance goal
|
|
||||||
# For now, use energy balance calculation
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
|
|
||||||
if balance is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Score based on whether deficit/surplus aligns with goal
|
|
||||||
# Simplified: assume weight loss goal = deficit is good
|
|
||||||
# TODO: Check actual goal type
|
|
||||||
|
|
||||||
abs_balance = abs(balance)
|
|
||||||
|
|
||||||
# Moderate deficit/surplus = good
|
|
||||||
if 200 <= abs_balance <= 500:
|
|
||||||
return 100
|
|
||||||
elif 100 <= abs_balance <= 700:
|
|
||||||
return 85
|
|
||||||
elif abs_balance <= 900:
|
|
||||||
return 70
|
|
||||||
elif abs_balance <= 1200:
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
return 40
|
|
||||||
|
|
||||||
|
|
||||||
def _score_macro_balance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score macro balance (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT protein_g, fat_g, carbs_g, kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND protein_g IS NOT NULL
|
|
||||||
AND fat_g IS NOT NULL
|
|
||||||
AND carbs_g IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(data) < 18:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate average macro percentages
|
|
||||||
macro_pcts = []
|
|
||||||
for row in data:
|
|
||||||
total_kcal = (row['protein_g'] * 4) + (row['fat_g'] * 9) + (row['carbs_g'] * 4)
|
|
||||||
if total_kcal == 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
protein_pct = (row['protein_g'] * 4 / total_kcal) * 100
|
|
||||||
fat_pct = (row['fat_g'] * 9 / total_kcal) * 100
|
|
||||||
carbs_pct = (row['carbs_g'] * 4 / total_kcal) * 100
|
|
||||||
|
|
||||||
macro_pcts.append((protein_pct, fat_pct, carbs_pct))
|
|
||||||
|
|
||||||
if not macro_pcts:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_protein_pct = sum(p for p, _, _ in macro_pcts) / len(macro_pcts)
|
|
||||||
avg_fat_pct = sum(f for _, f, _ in macro_pcts) / len(macro_pcts)
|
|
||||||
avg_carbs_pct = sum(c for _, _, c in macro_pcts) / len(macro_pcts)
|
|
||||||
|
|
||||||
# Reasonable ranges:
|
|
||||||
# Protein: 20-35%
|
|
||||||
# Fat: 20-35%
|
|
||||||
# Carbs: 30-55%
|
|
||||||
|
|
||||||
score = 100
|
|
||||||
|
|
||||||
# Protein score
|
|
||||||
if not (20 <= avg_protein_pct <= 35):
|
|
||||||
if avg_protein_pct < 20:
|
|
||||||
score -= (20 - avg_protein_pct) * 2
|
|
||||||
else:
|
|
||||||
score -= (avg_protein_pct - 35) * 1
|
|
||||||
|
|
||||||
# Fat score
|
|
||||||
if not (20 <= avg_fat_pct <= 35):
|
|
||||||
if avg_fat_pct < 20:
|
|
||||||
score -= (20 - avg_fat_pct) * 2
|
|
||||||
else:
|
|
||||||
score -= (avg_fat_pct - 35) * 2
|
|
||||||
|
|
||||||
# Carbs score
|
|
||||||
if not (30 <= avg_carbs_pct <= 55):
|
|
||||||
if avg_carbs_pct < 30:
|
|
||||||
score -= (30 - avg_carbs_pct) * 1.5
|
|
||||||
else:
|
|
||||||
score -= (avg_carbs_pct - 55) * 1.5
|
|
||||||
|
|
||||||
return max(40, min(100, int(score)))
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# E5: Energy Availability Warning (Heuristic)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_energy_availability_warning(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Heuristic energy availability warning
|
|
||||||
Returns dict with warning level and reasons
|
|
||||||
"""
|
|
||||||
warnings = []
|
|
||||||
severity = 'none' # none, low, medium, high
|
|
||||||
|
|
||||||
# 1. Check for sustained large deficit
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
if balance and balance < -800:
|
|
||||||
warnings.append('Anhaltend großes Energiedefizit (>800 kcal/Tag)')
|
|
||||||
severity = 'medium'
|
|
||||||
|
|
||||||
if balance < -1200:
|
|
||||||
warnings.append('Sehr großes Energiedefizit (>1200 kcal/Tag)')
|
|
||||||
severity = 'high'
|
|
||||||
|
|
||||||
# 2. Check recovery score
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
recovery = calculate_recovery_score_v2(profile_id)
|
|
||||||
if recovery and recovery < 50:
|
|
||||||
warnings.append('Recovery Score niedrig (<50)')
|
|
||||||
if severity == 'none':
|
|
||||||
severity = 'low'
|
|
||||||
elif severity == 'medium':
|
|
||||||
severity = 'high'
|
|
||||||
|
|
||||||
# 3. Check LBM trend
|
|
||||||
from calculations.body_metrics import calculate_lbm_28d_change
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
if lbm_change and lbm_change < -1.0:
|
|
||||||
warnings.append('Magermasse sinkt (>1kg in 28 Tagen)')
|
|
||||||
if severity == 'none':
|
|
||||||
severity = 'low'
|
|
||||||
elif severity in ['low', 'medium']:
|
|
||||||
severity = 'high'
|
|
||||||
|
|
||||||
# 4. Check sleep quality
|
|
||||||
from calculations.recovery_metrics import calculate_sleep_quality_7d
|
|
||||||
sleep_quality = calculate_sleep_quality_7d(profile_id)
|
|
||||||
if sleep_quality and sleep_quality < 60:
|
|
||||||
warnings.append('Schlafqualität verschlechtert')
|
|
||||||
if severity == 'none':
|
|
||||||
severity = 'low'
|
|
||||||
|
|
||||||
if not warnings:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
'severity': severity,
|
|
||||||
'warnings': warnings,
|
|
||||||
'recommendation': _get_energy_warning_recommendation(severity)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_energy_warning_recommendation(severity: str) -> str:
|
|
||||||
"""Get recommendation text based on severity"""
|
|
||||||
if severity == 'high':
|
|
||||||
return ("Mögliche Unterversorgung erkannt. Erwäge eine Reduktion des Energiedefizits, "
|
|
||||||
"Erhöhung der Proteinzufuhr und mehr Erholung. Dies ist keine medizinische Diagnose.")
|
|
||||||
elif severity == 'medium':
|
|
||||||
return ("Hinweise auf aggressives Defizit. Beobachte Recovery, Schlaf und Magermasse genau.")
|
|
||||||
else:
|
|
||||||
return ("Leichte Hinweise auf Belastung. Monitoring empfohlen.")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Additional Helper Metrics
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_fiber_avg_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate average fiber intake (g/day) last 7 days"""
|
|
||||||
# TODO: Implement when fiber column added to nutrition_log
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sugar_avg_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate average sugar intake (g/day) last 7 days"""
|
|
||||||
# TODO: Implement when sugar column added to nutrition_log
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_nutrition_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for nutrition metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Nutrition entries last 28 days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as total,
|
|
||||||
COUNT(protein_g) as with_protein,
|
|
||||||
COUNT(fat_g) as with_fat,
|
|
||||||
COUNT(carbs_g) as with_carbs
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
counts = cur.fetchone()
|
|
||||||
|
|
||||||
total_entries = counts['total']
|
|
||||||
protein_coverage = counts['with_protein'] / total_entries if total_entries > 0 else 0
|
|
||||||
macro_coverage = min(counts['with_fat'], counts['with_carbs']) / total_entries if total_entries > 0 else 0
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
frequency_score = min(100, (total_entries / 21) * 100) # 21 = 75% of 28 days
|
|
||||||
protein_score = protein_coverage * 100
|
|
||||||
macro_score = macro_coverage * 100
|
|
||||||
|
|
||||||
# Overall score (frequency 50%, protein 30%, macros 20%)
|
|
||||||
overall_score = int(
|
|
||||||
frequency_score * 0.5 +
|
|
||||||
protein_score * 0.3 +
|
|
||||||
macro_score * 0.2
|
|
||||||
)
|
|
||||||
|
|
||||||
# Confidence level
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"entries_28d": total_entries,
|
|
||||||
"protein_coverage_pct": int(protein_coverage * 100),
|
|
||||||
"macro_coverage_pct": int(macro_coverage * 100)
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"frequency": int(frequency_score),
|
|
||||||
"protein": int(protein_score),
|
|
||||||
"macros": int(macro_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,604 +0,0 @@
|
||||||
"""
|
|
||||||
Recovery Metrics Calculation Engine
|
|
||||||
|
|
||||||
Implements improved Recovery Score (S1 from visualization concept):
|
|
||||||
- HRV vs. baseline
|
|
||||||
- RHR vs. baseline
|
|
||||||
- Sleep duration vs. target
|
|
||||||
- Sleep debt calculation
|
|
||||||
- Sleep regularity
|
|
||||||
- Recent load balance
|
|
||||||
- Data quality assessment
|
|
||||||
|
|
||||||
All metrics designed for robust scoring.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional, Dict
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Recovery Score v2 (Improved from v9d)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_recovery_score_v2(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Improved recovery/readiness score (0-100)
|
|
||||||
|
|
||||||
Components:
|
|
||||||
- HRV status (25%)
|
|
||||||
- RHR status (20%)
|
|
||||||
- Sleep duration (20%)
|
|
||||||
- Sleep debt (10%)
|
|
||||||
- Sleep regularity (10%)
|
|
||||||
- Recent load balance (10%)
|
|
||||||
- Data quality (5%)
|
|
||||||
"""
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. HRV status (25%)
|
|
||||||
hrv_score = _score_hrv_vs_baseline(profile_id)
|
|
||||||
if hrv_score is not None:
|
|
||||||
components.append(('hrv', hrv_score, 25))
|
|
||||||
|
|
||||||
# 2. RHR status (20%)
|
|
||||||
rhr_score = _score_rhr_vs_baseline(profile_id)
|
|
||||||
if rhr_score is not None:
|
|
||||||
components.append(('rhr', rhr_score, 20))
|
|
||||||
|
|
||||||
# 3. Sleep duration (20%)
|
|
||||||
sleep_duration_score = _score_sleep_duration(profile_id)
|
|
||||||
if sleep_duration_score is not None:
|
|
||||||
components.append(('sleep_duration', sleep_duration_score, 20))
|
|
||||||
|
|
||||||
# 4. Sleep debt (10%)
|
|
||||||
sleep_debt_score = _score_sleep_debt(profile_id)
|
|
||||||
if sleep_debt_score is not None:
|
|
||||||
components.append(('sleep_debt', sleep_debt_score, 10))
|
|
||||||
|
|
||||||
# 5. Sleep regularity (10%)
|
|
||||||
regularity_score = _score_sleep_regularity(profile_id)
|
|
||||||
if regularity_score is not None:
|
|
||||||
components.append(('regularity', regularity_score, 10))
|
|
||||||
|
|
||||||
# 6. Recent load balance (10%)
|
|
||||||
load_score = _score_recent_load_balance(profile_id)
|
|
||||||
if load_score is not None:
|
|
||||||
components.append(('load', load_score, 10))
|
|
||||||
|
|
||||||
# 7. Data quality (5%)
|
|
||||||
quality_score = _score_recovery_data_quality(profile_id)
|
|
||||||
if quality_score is not None:
|
|
||||||
components.append(('data_quality', quality_score, 5))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
final_score = int(total_score / total_weight)
|
|
||||||
|
|
||||||
return final_score
|
|
||||||
|
|
||||||
|
|
||||||
def _score_hrv_vs_baseline(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score HRV relative to 28d baseline (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent HRV (last 3 days average)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as recent_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent_hrv = recent_row['recent_hrv']
|
|
||||||
|
|
||||||
# Get baseline (28d average, excluding last 3 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as baseline_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline_hrv = baseline_row['baseline_hrv']
|
|
||||||
|
|
||||||
# Calculate percentage deviation
|
|
||||||
deviation_pct = ((recent_hrv - baseline_hrv) / baseline_hrv) * 100
|
|
||||||
|
|
||||||
# Score: higher HRV = better recovery
|
|
||||||
if deviation_pct >= 10:
|
|
||||||
return 100
|
|
||||||
elif deviation_pct >= 5:
|
|
||||||
return 90
|
|
||||||
elif deviation_pct >= 0:
|
|
||||||
return 75
|
|
||||||
elif deviation_pct >= -5:
|
|
||||||
return 60
|
|
||||||
elif deviation_pct >= -10:
|
|
||||||
return 45
|
|
||||||
else:
|
|
||||||
return max(20, 45 + int(deviation_pct * 2))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_rhr_vs_baseline(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score RHR relative to 28d baseline (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent RHR (last 3 days average)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as recent_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent_rhr = recent_row['recent_rhr']
|
|
||||||
|
|
||||||
# Get baseline (28d average, excluding last 3 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as baseline_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline_rhr = baseline_row['baseline_rhr']
|
|
||||||
|
|
||||||
# Calculate difference (bpm)
|
|
||||||
difference = recent_rhr - baseline_rhr
|
|
||||||
|
|
||||||
# Score: lower RHR = better recovery
|
|
||||||
if difference <= -3:
|
|
||||||
return 100
|
|
||||||
elif difference <= -1:
|
|
||||||
return 90
|
|
||||||
elif difference <= 1:
|
|
||||||
return 75
|
|
||||||
elif difference <= 3:
|
|
||||||
return 60
|
|
||||||
elif difference <= 5:
|
|
||||||
return 45
|
|
||||||
else:
|
|
||||||
return max(20, 45 - (difference * 5))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_duration(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score recent sleep duration (0-100)"""
|
|
||||||
avg_sleep_hours = calculate_sleep_avg_duration_7d(profile_id)
|
|
||||||
|
|
||||||
if avg_sleep_hours is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Target: 7-9 hours
|
|
||||||
if 7 <= avg_sleep_hours <= 9:
|
|
||||||
return 100
|
|
||||||
elif 6.5 <= avg_sleep_hours < 7:
|
|
||||||
return 85
|
|
||||||
elif 6 <= avg_sleep_hours < 6.5:
|
|
||||||
return 70
|
|
||||||
elif avg_sleep_hours >= 9.5:
|
|
||||||
return 85 # Too much sleep can indicate fatigue
|
|
||||||
else:
|
|
||||||
return max(40, int(avg_sleep_hours * 10))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_debt(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score sleep debt (0-100)"""
|
|
||||||
debt_hours = calculate_sleep_debt_hours(profile_id)
|
|
||||||
|
|
||||||
if debt_hours is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Score based on accumulated debt
|
|
||||||
if debt_hours <= 1:
|
|
||||||
return 100
|
|
||||||
elif debt_hours <= 3:
|
|
||||||
return 85
|
|
||||||
elif debt_hours <= 5:
|
|
||||||
return 70
|
|
||||||
elif debt_hours <= 8:
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
return max(30, 100 - (debt_hours * 8))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_regularity(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score sleep regularity (0-100)"""
|
|
||||||
regularity_proxy = calculate_sleep_regularity_proxy(profile_id)
|
|
||||||
|
|
||||||
if regularity_proxy is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# regularity_proxy = mean absolute shift in minutes
|
|
||||||
# Lower = better
|
|
||||||
if regularity_proxy <= 30:
|
|
||||||
return 100
|
|
||||||
elif regularity_proxy <= 45:
|
|
||||||
return 85
|
|
||||||
elif regularity_proxy <= 60:
|
|
||||||
return 70
|
|
||||||
elif regularity_proxy <= 90:
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
return max(30, 100 - int(regularity_proxy / 2))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_recent_load_balance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score recent training load balance (0-100)"""
|
|
||||||
load_3d = calculate_recent_load_balance_3d(profile_id)
|
|
||||||
|
|
||||||
if load_3d is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Proxy load: 0-300 = low, 300-600 = moderate, >600 = high
|
|
||||||
if load_3d < 300:
|
|
||||||
# Under-loading
|
|
||||||
return 90
|
|
||||||
elif load_3d <= 600:
|
|
||||||
# Optimal
|
|
||||||
return 100
|
|
||||||
elif load_3d <= 900:
|
|
||||||
# High but manageable
|
|
||||||
return 75
|
|
||||||
elif load_3d <= 1200:
|
|
||||||
# Very high
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
# Excessive
|
|
||||||
return max(30, 100 - (load_3d / 20))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_recovery_data_quality(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score data quality for recovery metrics (0-100)"""
|
|
||||||
quality = calculate_recovery_data_quality(profile_id)
|
|
||||||
return quality['overall_score']
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Individual Recovery Metrics
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_hrv_vs_baseline_pct(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate HRV deviation from baseline (percentage)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Recent HRV (3d avg)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as recent_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = recent_row['recent_hrv']
|
|
||||||
|
|
||||||
# Baseline (28d avg, excluding last 3d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as baseline_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline = baseline_row['baseline_hrv']
|
|
||||||
|
|
||||||
deviation_pct = ((recent - baseline) / baseline) * 100
|
|
||||||
return round(deviation_pct, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_rhr_vs_baseline_pct(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate RHR deviation from baseline (percentage)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Recent RHR (3d avg)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as recent_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = recent_row['recent_rhr']
|
|
||||||
|
|
||||||
# Baseline
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as baseline_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline = baseline_row['baseline_rhr']
|
|
||||||
|
|
||||||
deviation_pct = ((recent - baseline) / baseline) * 100
|
|
||||||
return round(deviation_pct, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate average sleep duration (hours) last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(duration_minutes) as avg_sleep_min
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or not row['avg_sleep_min']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_hours = row['avg_sleep_min'] / 60
|
|
||||||
return round(avg_hours, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate accumulated sleep debt (hours) last 14 days
|
|
||||||
Assumes 7.5h target per night
|
|
||||||
"""
|
|
||||||
target_hours = 7.5
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = [row['duration_minutes'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(sleep_data) < 10: # Need at least 10 days
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate cumulative debt
|
|
||||||
total_debt_min = sum(max(0, (target_hours * 60) - sleep_min) for sleep_min in sleep_data)
|
|
||||||
debt_hours = total_debt_min / 60
|
|
||||||
|
|
||||||
return round(debt_hours, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Sleep regularity proxy: mean absolute shift from previous day (minutes)
|
|
||||||
Lower = more regular
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT bedtime, wake_time, date
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
AND bedtime IS NOT NULL
|
|
||||||
AND wake_time IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(sleep_data) < 7:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate day-to-day shifts
|
|
||||||
shifts = []
|
|
||||||
for i in range(1, len(sleep_data)):
|
|
||||||
prev = sleep_data[i-1]
|
|
||||||
curr = sleep_data[i]
|
|
||||||
|
|
||||||
# Bedtime shift (minutes)
|
|
||||||
prev_bedtime = prev['bedtime']
|
|
||||||
curr_bedtime = curr['bedtime']
|
|
||||||
|
|
||||||
# Convert to minutes since midnight
|
|
||||||
prev_bed_min = prev_bedtime.hour * 60 + prev_bedtime.minute
|
|
||||||
curr_bed_min = curr_bedtime.hour * 60 + curr_bedtime.minute
|
|
||||||
|
|
||||||
# Handle cross-midnight (e.g., 23:00 to 01:00)
|
|
||||||
bed_shift = abs(curr_bed_min - prev_bed_min)
|
|
||||||
if bed_shift > 720: # More than 12 hours = wrapped around
|
|
||||||
bed_shift = 1440 - bed_shift
|
|
||||||
|
|
||||||
shifts.append(bed_shift)
|
|
||||||
|
|
||||||
mean_shift = sum(shifts) / len(shifts)
|
|
||||||
return round(mean_shift, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_recent_load_balance_3d(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate proxy internal load last 3 days"""
|
|
||||||
from calculations.activity_metrics import calculate_proxy_internal_load_7d
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT SUM(duration_min) as total_duration
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Simplified 3d load (duration-based)
|
|
||||||
return int(row['total_duration'] or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate sleep quality score (0-100) based on deep+REM percentage
|
|
||||||
Last 7 days
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_minutes, deep_minutes, rem_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(sleep_data) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
quality_scores = []
|
|
||||||
for s in sleep_data:
|
|
||||||
if s['deep_minutes'] and s['rem_minutes']:
|
|
||||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
|
||||||
# 40-60% deep+REM is good
|
|
||||||
if quality_pct >= 45:
|
|
||||||
quality_scores.append(100)
|
|
||||||
elif quality_pct >= 35:
|
|
||||||
quality_scores.append(75)
|
|
||||||
elif quality_pct >= 25:
|
|
||||||
quality_scores.append(50)
|
|
||||||
else:
|
|
||||||
quality_scores.append(30)
|
|
||||||
|
|
||||||
if not quality_scores:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_quality = sum(quality_scores) / len(quality_scores)
|
|
||||||
return int(avg_quality)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_recovery_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for recovery metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# HRV measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as hrv_count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
hrv_count = cur.fetchone()['hrv_count']
|
|
||||||
|
|
||||||
# RHR measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as rhr_count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
rhr_count = cur.fetchone()['rhr_count']
|
|
||||||
|
|
||||||
# Sleep measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as sleep_count
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
sleep_count = cur.fetchone()['sleep_count']
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
hrv_score = min(100, (hrv_count / 21) * 100) # 21 = 75% coverage
|
|
||||||
rhr_score = min(100, (rhr_count / 21) * 100)
|
|
||||||
sleep_score = min(100, (sleep_count / 21) * 100)
|
|
||||||
|
|
||||||
# Overall score
|
|
||||||
overall_score = int(
|
|
||||||
hrv_score * 0.3 +
|
|
||||||
rhr_score * 0.3 +
|
|
||||||
sleep_score * 0.4
|
|
||||||
)
|
|
||||||
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"hrv_28d": hrv_count,
|
|
||||||
"rhr_28d": rhr_count,
|
|
||||||
"sleep_28d": sleep_count
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"hrv": int(hrv_score),
|
|
||||||
"rhr": int(rhr_score),
|
|
||||||
"sleep": int(sleep_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,573 +0,0 @@
|
||||||
"""
|
|
||||||
Score Calculation Engine
|
|
||||||
|
|
||||||
Implements meta-scores with Dynamic Focus Areas v2.0 integration:
|
|
||||||
- Goal Progress Score (weighted by user's focus areas)
|
|
||||||
- Data Quality Score
|
|
||||||
- Helper functions for focus area weighting
|
|
||||||
|
|
||||||
All scores are 0-100 with confidence levels.
|
|
||||||
"""
|
|
||||||
from typing import Dict, Optional, List
|
|
||||||
import json
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Focus Area Weighting System
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_user_focus_weights(profile_id: str) -> Dict[str, float]:
|
|
||||||
"""
|
|
||||||
Get user's focus area weights as dictionary
|
|
||||||
Returns: {'körpergewicht': 30.0, 'kraftaufbau': 25.0, ...}
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT ufw.focus_area_id, ufw.weight as weight_pct, fa.key
|
|
||||||
FROM user_focus_area_weights ufw
|
|
||||||
JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id
|
|
||||||
WHERE ufw.profile_id = %s
|
|
||||||
AND ufw.weight > 0
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
return {
|
|
||||||
row['key']: float(row['weight_pct'])
|
|
||||||
for row in cur.fetchall()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_focus_area_category(focus_area_id: str) -> Optional[str]:
|
|
||||||
"""Get category for a focus area"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT category
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE focus_area_id = %s
|
|
||||||
""", (focus_area_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return row['category'] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def map_focus_to_score_components() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Map focus areas to score components
|
|
||||||
Keys match focus_area_definitions.key (English lowercase)
|
|
||||||
Returns: {'weight_loss': 'body', 'strength': 'activity', ...}
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
# Body Composition → body_progress_score
|
|
||||||
'weight_loss': 'body',
|
|
||||||
'muscle_gain': 'body',
|
|
||||||
'body_recomposition': 'body',
|
|
||||||
|
|
||||||
# Training - Strength → activity_score
|
|
||||||
'strength': 'activity',
|
|
||||||
'strength_endurance': 'activity',
|
|
||||||
'power': 'activity',
|
|
||||||
|
|
||||||
# Training - Mobility → activity_score
|
|
||||||
'flexibility': 'activity',
|
|
||||||
'mobility': 'activity',
|
|
||||||
|
|
||||||
# Endurance → activity_score (could also map to health)
|
|
||||||
'aerobic_endurance': 'activity',
|
|
||||||
'anaerobic_endurance': 'activity',
|
|
||||||
'cardiovascular_health': 'health',
|
|
||||||
|
|
||||||
# Coordination → activity_score
|
|
||||||
'balance': 'activity',
|
|
||||||
'reaction': 'activity',
|
|
||||||
'rhythm': 'activity',
|
|
||||||
'coordination': 'activity',
|
|
||||||
|
|
||||||
# Mental → recovery_score (mental health is part of recovery)
|
|
||||||
'stress_resistance': 'recovery',
|
|
||||||
'concentration': 'recovery',
|
|
||||||
'willpower': 'recovery',
|
|
||||||
'mental_health': 'recovery',
|
|
||||||
|
|
||||||
# Recovery → recovery_score
|
|
||||||
'sleep_quality': 'recovery',
|
|
||||||
'regeneration': 'recovery',
|
|
||||||
'rest': 'recovery',
|
|
||||||
|
|
||||||
# Health → health
|
|
||||||
'metabolic_health': 'health',
|
|
||||||
'blood_pressure': 'health',
|
|
||||||
'hrv': 'health',
|
|
||||||
'general_health': 'health',
|
|
||||||
|
|
||||||
# Nutrition → nutrition_score
|
|
||||||
'protein_intake': 'nutrition',
|
|
||||||
'calorie_balance': 'nutrition',
|
|
||||||
'macro_consistency': 'nutrition',
|
|
||||||
'meal_timing': 'nutrition',
|
|
||||||
'hydration': 'nutrition',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def map_category_de_to_en(category_de: str) -> str:
|
|
||||||
"""
|
|
||||||
Map German category names to English database names
|
|
||||||
"""
|
|
||||||
mapping = {
|
|
||||||
'körper': 'body_composition',
|
|
||||||
'ernährung': 'nutrition', # Note: no nutrition category in DB, returns empty
|
|
||||||
'aktivität': 'training',
|
|
||||||
'recovery': 'recovery',
|
|
||||||
'vitalwerte': 'health',
|
|
||||||
'mental': 'mental',
|
|
||||||
'lebensstil': 'health', # Maps to general health
|
|
||||||
}
|
|
||||||
return mapping.get(category_de, category_de)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_category_weight(profile_id: str, category: str) -> float:
|
|
||||||
"""
|
|
||||||
Calculate total weight for a category
|
|
||||||
Accepts German or English category names
|
|
||||||
Returns sum of all focus area weights in this category
|
|
||||||
"""
|
|
||||||
# Map German to English if needed
|
|
||||||
category_en = map_category_de_to_en(category)
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT key
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE category = %s
|
|
||||||
""", (category_en,))
|
|
||||||
|
|
||||||
focus_areas = [row['key'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
total_weight = sum(
|
|
||||||
focus_weights.get(fa, 0)
|
|
||||||
for fa in focus_areas
|
|
||||||
)
|
|
||||||
|
|
||||||
return total_weight
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Goal Progress Score (Meta-Score with Dynamic Weighting)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_goal_progress_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate overall goal progress score (0-100)
|
|
||||||
Weighted dynamically based on user's focus area priorities
|
|
||||||
|
|
||||||
This is the main meta-score that combines all sub-scores
|
|
||||||
"""
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None # No goals/focus areas configured
|
|
||||||
|
|
||||||
# Calculate sub-scores
|
|
||||||
from calculations.body_metrics import calculate_body_progress_score
|
|
||||||
from calculations.nutrition_metrics import calculate_nutrition_score
|
|
||||||
from calculations.activity_metrics import calculate_activity_score
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
|
|
||||||
body_score = calculate_body_progress_score(profile_id, focus_weights)
|
|
||||||
nutrition_score = calculate_nutrition_score(profile_id, focus_weights)
|
|
||||||
activity_score = calculate_activity_score(profile_id, focus_weights)
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
health_risk_score = calculate_health_stability_score(profile_id)
|
|
||||||
|
|
||||||
# Map focus areas to score components
|
|
||||||
focus_to_component = map_focus_to_score_components()
|
|
||||||
|
|
||||||
# Calculate weighted sum
|
|
||||||
total_score = 0.0
|
|
||||||
total_weight = 0.0
|
|
||||||
|
|
||||||
for focus_area_id, weight in focus_weights.items():
|
|
||||||
component = focus_to_component.get(focus_area_id)
|
|
||||||
|
|
||||||
if component == 'body' and body_score is not None:
|
|
||||||
total_score += body_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'nutrition' and nutrition_score is not None:
|
|
||||||
total_score += nutrition_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'activity' and activity_score is not None:
|
|
||||||
total_score += activity_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'recovery' and recovery_score is not None:
|
|
||||||
total_score += recovery_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'health' and health_risk_score is not None:
|
|
||||||
total_score += health_risk_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
|
|
||||||
if total_weight == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Normalize to 0-100
|
|
||||||
final_score = total_score / total_weight
|
|
||||||
|
|
||||||
return int(final_score)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Health stability score (0-100)
|
|
||||||
Components:
|
|
||||||
- Blood pressure status
|
|
||||||
- Sleep quality
|
|
||||||
- Movement baseline
|
|
||||||
- Weight/circumference risk factors
|
|
||||||
- Regularity
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Blood pressure status (30%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT systolic, diastolic
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND measured_at >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
bp_readings = cur.fetchall()
|
|
||||||
if bp_readings:
|
|
||||||
bp_score = _score_blood_pressure(bp_readings)
|
|
||||||
components.append(('bp', bp_score, 30))
|
|
||||||
|
|
||||||
# 2. Sleep quality (25%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_minutes, deep_minutes, rem_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
if sleep_data:
|
|
||||||
sleep_score = _score_sleep_quality(sleep_data)
|
|
||||||
components.append(('sleep', sleep_score, 25))
|
|
||||||
|
|
||||||
# 3. Movement baseline (20%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_min
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
if activities:
|
|
||||||
total_minutes = sum(a['duration_min'] for a in activities)
|
|
||||||
# WHO recommends 150-300 min/week moderate activity
|
|
||||||
movement_score = min(100, (total_minutes / 150) * 100)
|
|
||||||
components.append(('movement', movement_score, 20))
|
|
||||||
|
|
||||||
# 4. Waist circumference risk (15%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c_waist
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND c_waist IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
waist = cur.fetchone()
|
|
||||||
if waist:
|
|
||||||
# Gender-specific thresholds (simplified - should use profile gender)
|
|
||||||
# Men: <94cm good, 94-102 elevated, >102 high risk
|
|
||||||
# Women: <80cm good, 80-88 elevated, >88 high risk
|
|
||||||
# Using conservative thresholds
|
|
||||||
waist_cm = waist['c_waist']
|
|
||||||
if waist_cm < 88:
|
|
||||||
waist_score = 100
|
|
||||||
elif waist_cm < 94:
|
|
||||||
waist_score = 75
|
|
||||||
elif waist_cm < 102:
|
|
||||||
waist_score = 50
|
|
||||||
else:
|
|
||||||
waist_score = 25
|
|
||||||
components.append(('waist', waist_score, 15))
|
|
||||||
|
|
||||||
# 5. Regularity (10%) - sleep timing consistency
|
|
||||||
if len(sleep_data) >= 7:
|
|
||||||
sleep_times = [s['duration_minutes'] for s in sleep_data]
|
|
||||||
avg = sum(sleep_times) / len(sleep_times)
|
|
||||||
variance = sum((x - avg) ** 2 for x in sleep_times) / len(sleep_times)
|
|
||||||
std_dev = variance ** 0.5
|
|
||||||
# Lower std_dev = better consistency
|
|
||||||
regularity_score = max(0, 100 - (std_dev * 2))
|
|
||||||
components.append(('regularity', regularity_score, 10))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_blood_pressure(readings: List) -> int:
|
|
||||||
"""Score blood pressure readings (0-100)"""
|
|
||||||
# Average last 28 days
|
|
||||||
avg_systolic = sum(r['systolic'] for r in readings) / len(readings)
|
|
||||||
avg_diastolic = sum(r['diastolic'] for r in readings) / len(readings)
|
|
||||||
|
|
||||||
# ESC 2024 Guidelines:
|
|
||||||
# Optimal: <120/80
|
|
||||||
# Normal: 120-129 / 80-84
|
|
||||||
# Elevated: 130-139 / 85-89
|
|
||||||
# Hypertension: ≥140/90
|
|
||||||
|
|
||||||
if avg_systolic < 120 and avg_diastolic < 80:
|
|
||||||
return 100
|
|
||||||
elif avg_systolic < 130 and avg_diastolic < 85:
|
|
||||||
return 85
|
|
||||||
elif avg_systolic < 140 and avg_diastolic < 90:
|
|
||||||
return 65
|
|
||||||
else:
|
|
||||||
return 40
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_quality(sleep_data: List) -> int:
|
|
||||||
"""Score sleep quality (0-100)"""
|
|
||||||
# Average sleep duration and quality
|
|
||||||
avg_total = sum(s['duration_minutes'] for s in sleep_data) / len(sleep_data)
|
|
||||||
avg_total_hours = avg_total / 60
|
|
||||||
|
|
||||||
# Duration score (7+ hours = good)
|
|
||||||
if avg_total_hours >= 8:
|
|
||||||
duration_score = 100
|
|
||||||
elif avg_total_hours >= 7:
|
|
||||||
duration_score = 85
|
|
||||||
elif avg_total_hours >= 6:
|
|
||||||
duration_score = 65
|
|
||||||
else:
|
|
||||||
duration_score = 40
|
|
||||||
|
|
||||||
# Quality score (deep + REM percentage)
|
|
||||||
quality_scores = []
|
|
||||||
for s in sleep_data:
|
|
||||||
if s['deep_minutes'] and s['rem_minutes']:
|
|
||||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
|
||||||
# 40-60% deep+REM is good
|
|
||||||
if quality_pct >= 45:
|
|
||||||
quality_scores.append(100)
|
|
||||||
elif quality_pct >= 35:
|
|
||||||
quality_scores.append(75)
|
|
||||||
elif quality_pct >= 25:
|
|
||||||
quality_scores.append(50)
|
|
||||||
else:
|
|
||||||
quality_scores.append(30)
|
|
||||||
|
|
||||||
if quality_scores:
|
|
||||||
avg_quality = sum(quality_scores) / len(quality_scores)
|
|
||||||
# Weighted: 60% duration, 40% quality
|
|
||||||
return int(duration_score * 0.6 + avg_quality * 0.4)
|
|
||||||
else:
|
|
||||||
return duration_score
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Score
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_data_quality_score(profile_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Overall data quality score (0-100)
|
|
||||||
Combines quality from all modules
|
|
||||||
"""
|
|
||||||
from calculations.body_metrics import calculate_body_data_quality
|
|
||||||
from calculations.nutrition_metrics import calculate_nutrition_data_quality
|
|
||||||
from calculations.activity_metrics import calculate_activity_data_quality
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_data_quality
|
|
||||||
|
|
||||||
body_quality = calculate_body_data_quality(profile_id)
|
|
||||||
nutrition_quality = calculate_nutrition_data_quality(profile_id)
|
|
||||||
activity_quality = calculate_activity_data_quality(profile_id)
|
|
||||||
recovery_quality = calculate_recovery_data_quality(profile_id)
|
|
||||||
|
|
||||||
# Weighted average (all equal weight)
|
|
||||||
total_score = (
|
|
||||||
body_quality['overall_score'] * 0.25 +
|
|
||||||
nutrition_quality['overall_score'] * 0.25 +
|
|
||||||
activity_quality['overall_score'] * 0.25 +
|
|
||||||
recovery_quality['overall_score'] * 0.25
|
|
||||||
)
|
|
||||||
|
|
||||||
return int(total_score)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Top-Weighted Helpers (instead of "primary goal")
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_top_priority_goal(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Get highest priority goal based on:
|
|
||||||
- Progress gap (distance to target)
|
|
||||||
- Focus area weight
|
|
||||||
Returns goal dict or None
|
|
||||||
"""
|
|
||||||
from goal_utils import get_active_goals
|
|
||||||
|
|
||||||
goals = get_active_goals(profile_id)
|
|
||||||
if not goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
for goal in goals:
|
|
||||||
# Progress gap (0-100, higher = further from target)
|
|
||||||
goal['progress_gap'] = 100 - (goal.get('progress_pct') or 0)
|
|
||||||
|
|
||||||
# Get focus areas for this goal
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT fa.key as focus_area_key
|
|
||||||
FROM goal_focus_contributions gfc
|
|
||||||
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
|
|
||||||
WHERE gfc.goal_id = %s
|
|
||||||
""", (goal['id'],))
|
|
||||||
|
|
||||||
goal_focus_areas = [row['focus_area_key'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Sum focus weights
|
|
||||||
goal['total_focus_weight'] = sum(
|
|
||||||
focus_weights.get(fa, 0)
|
|
||||||
for fa in goal_focus_areas
|
|
||||||
)
|
|
||||||
|
|
||||||
# Priority score
|
|
||||||
goal['priority_score'] = goal['progress_gap'] * (goal['total_focus_weight'] / 100)
|
|
||||||
|
|
||||||
# Return goal with highest priority score
|
|
||||||
return max(goals, key=lambda g: g.get('priority_score', 0))
|
|
||||||
|
|
||||||
|
|
||||||
def get_top_focus_area(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Get focus area with highest user weight
|
|
||||||
Returns dict with focus_area_id, label, weight, progress
|
|
||||||
"""
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None
|
|
||||||
|
|
||||||
top_fa_id = max(focus_weights, key=focus_weights.get)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT key, name_de, category
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE key = %s
|
|
||||||
""", (top_fa_id,))
|
|
||||||
|
|
||||||
fa_def = cur.fetchone()
|
|
||||||
if not fa_def:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate progress for this focus area
|
|
||||||
progress = calculate_focus_area_progress(profile_id, top_fa_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'focus_area_id': top_fa_id,
|
|
||||||
'label': fa_def['name_de'],
|
|
||||||
'category': fa_def['category'],
|
|
||||||
'weight': focus_weights[top_fa_id],
|
|
||||||
'progress': progress
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate progress for a specific focus area (0-100)
|
|
||||||
Average progress of all goals contributing to this focus area
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT g.id, g.progress_pct, gfc.contribution_weight
|
|
||||||
FROM goals g
|
|
||||||
JOIN goal_focus_contributions gfc ON g.id = gfc.goal_id
|
|
||||||
WHERE g.profile_id = %s
|
|
||||||
AND gfc.focus_area_id = (
|
|
||||||
SELECT id FROM focus_area_definitions WHERE key = %s
|
|
||||||
)
|
|
||||||
AND g.status = 'active'
|
|
||||||
""", (profile_id, focus_area_id))
|
|
||||||
|
|
||||||
goals = cur.fetchall()
|
|
||||||
|
|
||||||
if not goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average by contribution_weight
|
|
||||||
total_progress = sum(g['progress_pct'] * g['contribution_weight'] for g in goals)
|
|
||||||
total_weight = sum(g['contribution_weight'] for g in goals)
|
|
||||||
|
|
||||||
return int(total_progress / total_weight) if total_weight > 0 else None
|
|
||||||
|
|
||||||
def calculate_category_progress(profile_id: str, category: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate progress score for a focus area category (0-100).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User's profile ID
|
|
||||||
category: Category name ('körper', 'ernährung', 'aktivität', 'recovery', 'vitalwerte', 'mental', 'lebensstil')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Progress score 0-100 or None if no data
|
|
||||||
"""
|
|
||||||
# Map category to score calculation functions
|
|
||||||
category_scores = {
|
|
||||||
'körper': 'body_progress_score',
|
|
||||||
'ernährung': 'nutrition_score',
|
|
||||||
'aktivität': 'activity_score',
|
|
||||||
'recovery': 'recovery_score',
|
|
||||||
'vitalwerte': 'recovery_score', # Use recovery score as proxy for vitals
|
|
||||||
'mental': 'recovery_score', # Use recovery score as proxy for mental (sleep quality)
|
|
||||||
'lebensstil': 'data_quality_score', # Use data quality as proxy for lifestyle consistency
|
|
||||||
}
|
|
||||||
|
|
||||||
score_func_name = category_scores.get(category.lower())
|
|
||||||
if not score_func_name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Call the appropriate score function
|
|
||||||
if score_func_name == 'body_progress_score':
|
|
||||||
from calculations.body_metrics import calculate_body_progress_score
|
|
||||||
return calculate_body_progress_score(profile_id)
|
|
||||||
elif score_func_name == 'nutrition_score':
|
|
||||||
from calculations.nutrition_metrics import calculate_nutrition_score
|
|
||||||
return calculate_nutrition_score(profile_id)
|
|
||||||
elif score_func_name == 'activity_score':
|
|
||||||
from calculations.activity_metrics import calculate_activity_score
|
|
||||||
return calculate_activity_score(profile_id)
|
|
||||||
elif score_func_name == 'recovery_score':
|
|
||||||
from calculations.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
return calculate_recovery_score_v2(profile_id)
|
|
||||||
elif score_func_name == 'data_quality_score':
|
|
||||||
return calculate_data_quality_score(profile_id)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Quick diagnostic script to check features table."""
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
print("\n=== FEATURES TABLE ===")
|
|
||||||
cur.execute("SELECT id, name, active, limit_type, reset_period FROM features ORDER BY id")
|
|
||||||
features = cur.fetchall()
|
|
||||||
|
|
||||||
if not features:
|
|
||||||
print("❌ NO FEATURES FOUND! Migration failed!")
|
|
||||||
else:
|
|
||||||
for r in features:
|
|
||||||
print(f" {r['id']:30} {r['name']:40} active={r['active']} type={r['limit_type']:8} reset={r['reset_period']}")
|
|
||||||
|
|
||||||
print(f"\nTotal features: {len(features)}")
|
|
||||||
|
|
||||||
print("\n=== USER_FEATURE_USAGE (recent) ===")
|
|
||||||
cur.execute("""
|
|
||||||
SELECT profile_id, feature_id, usage_count, reset_at
|
|
||||||
FROM user_feature_usage
|
|
||||||
ORDER BY updated DESC
|
|
||||||
LIMIT 10
|
|
||||||
""")
|
|
||||||
usages = cur.fetchall()
|
|
||||||
|
|
||||||
if not usages:
|
|
||||||
print(" (no usage records yet)")
|
|
||||||
else:
|
|
||||||
for r in usages:
|
|
||||||
print(f" {r['profile_id'][:8]}... -> {r['feature_id']:30} used={r['usage_count']} reset_at={r['reset_at']}")
|
|
||||||
|
|
||||||
print(f"\nTotal usage records: {len(usages)}")
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Quick diagnostic: Check Migration 024 state
|
|
||||||
|
|
||||||
Run this inside the backend container:
|
|
||||||
docker exec bodytrack-dev-backend-1 python check_migration_024.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
import os
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
# Database connection
|
|
||||||
DB_HOST = os.getenv('DB_HOST', 'db')
|
|
||||||
DB_PORT = os.getenv('DB_PORT', '5432')
|
|
||||||
DB_NAME = os.getenv('DB_NAME', 'bodytrack')
|
|
||||||
DB_USER = os.getenv('DB_USER', 'bodytrack')
|
|
||||||
DB_PASS = os.getenv('DB_PASSWORD', '')
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 70)
|
|
||||||
print("Migration 024 Diagnostic")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# Connect to database
|
|
||||||
conn = psycopg2.connect(
|
|
||||||
host=DB_HOST,
|
|
||||||
port=DB_PORT,
|
|
||||||
dbname=DB_NAME,
|
|
||||||
user=DB_USER,
|
|
||||||
password=DB_PASS
|
|
||||||
)
|
|
||||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
|
||||||
|
|
||||||
# 1. Check if table exists
|
|
||||||
print("\n1. Checking if goal_type_definitions table exists...")
|
|
||||||
cur.execute("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'goal_type_definitions'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
exists = cur.fetchone()['exists']
|
|
||||||
print(f" ✓ Table exists: {exists}")
|
|
||||||
|
|
||||||
if not exists:
|
|
||||||
print("\n❌ TABLE DOES NOT EXIST - Migration 024 did not run!")
|
|
||||||
print("\nRECOMMENDED ACTION:")
|
|
||||||
print(" 1. Restart backend container: docker restart bodytrack-dev-backend-1")
|
|
||||||
print(" 2. Check logs: docker logs bodytrack-dev-backend-1 | grep 'Migration'")
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. Check row count
|
|
||||||
print("\n2. Checking row count...")
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions")
|
|
||||||
count = cur.fetchone()['count']
|
|
||||||
print(f" Row count: {count}")
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
print("\n❌ TABLE IS EMPTY - Seed data was not inserted!")
|
|
||||||
print("\nPOSSIBLE CAUSES:")
|
|
||||||
print(" - INSERT statements failed (constraint violation?)")
|
|
||||||
print(" - Migration ran partially")
|
|
||||||
print("\nRECOMMENDED ACTION:")
|
|
||||||
print(" Run the seed statements manually (see below)")
|
|
||||||
else:
|
|
||||||
print(f" ✓ Table has {count} entries")
|
|
||||||
|
|
||||||
# 3. Show all entries
|
|
||||||
print("\n3. Current goal type definitions:")
|
|
||||||
cur.execute("""
|
|
||||||
SELECT type_key, label_de, unit, is_system, is_active, created_at
|
|
||||||
FROM goal_type_definitions
|
|
||||||
ORDER BY is_system DESC, type_key
|
|
||||||
""")
|
|
||||||
|
|
||||||
entries = cur.fetchall()
|
|
||||||
if entries:
|
|
||||||
print(f"\n {'Type Key':<20} {'Label':<20} {'Unit':<10} {'System':<8} {'Active':<8}")
|
|
||||||
print(" " + "-" * 70)
|
|
||||||
for row in entries:
|
|
||||||
status = "SYSTEM" if row['is_system'] else "CUSTOM"
|
|
||||||
active = "YES" if row['is_active'] else "NO"
|
|
||||||
print(f" {row['type_key']:<20} {row['label_de']:<20} {row['unit']:<10} {status:<8} {active:<8}")
|
|
||||||
else:
|
|
||||||
print(" (empty)")
|
|
||||||
|
|
||||||
# 4. Check schema_migrations
|
|
||||||
print("\n4. Checking schema_migrations tracking...")
|
|
||||||
cur.execute("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_name = 'schema_migrations'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
sm_exists = cur.fetchone()['exists']
|
|
||||||
|
|
||||||
if sm_exists:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT filename, executed_at
|
|
||||||
FROM schema_migrations
|
|
||||||
WHERE filename = '024_goal_type_registry.sql'
|
|
||||||
""")
|
|
||||||
tracked = cur.fetchone()
|
|
||||||
if tracked:
|
|
||||||
print(f" ✓ Migration 024 is tracked (executed: {tracked['executed_at']})")
|
|
||||||
else:
|
|
||||||
print(" ❌ Migration 024 is NOT tracked in schema_migrations")
|
|
||||||
else:
|
|
||||||
print(" ⚠️ schema_migrations table does not exist")
|
|
||||||
|
|
||||||
# 5. Check for errors
|
|
||||||
print("\n5. Potential issues:")
|
|
||||||
issues = []
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
issues.append("No seed data - INSERTs failed")
|
|
||||||
|
|
||||||
if count > 0 and count < 6:
|
|
||||||
issues.append(f"Only {count} types (expected 8) - partial seed")
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as inactive_count
|
|
||||||
FROM goal_type_definitions
|
|
||||||
WHERE is_active = false
|
|
||||||
""")
|
|
||||||
inactive = cur.fetchone()['inactive_count']
|
|
||||||
if inactive > 2:
|
|
||||||
issues.append(f"{inactive} inactive types (expected 2)")
|
|
||||||
|
|
||||||
if not issues:
|
|
||||||
print(" ✓ No issues detected")
|
|
||||||
else:
|
|
||||||
for issue in issues:
|
|
||||||
print(f" ❌ {issue}")
|
|
||||||
|
|
||||||
# 6. Test query that frontend uses
|
|
||||||
print("\n6. Testing frontend query (WHERE is_active = true)...")
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as active_count
|
|
||||||
FROM goal_type_definitions
|
|
||||||
WHERE is_active = true
|
|
||||||
""")
|
|
||||||
active_count = cur.fetchone()['active_count']
|
|
||||||
print(f" Active types returned: {active_count}")
|
|
||||||
|
|
||||||
if active_count == 0:
|
|
||||||
print(" ❌ This is why frontend shows empty list!")
|
|
||||||
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("SUMMARY")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
print("\n🔴 PROBLEM: Table exists but has no data")
|
|
||||||
print("\nQUICK FIX: Run these SQL commands manually:")
|
|
||||||
print("\n```sql")
|
|
||||||
print("-- Connect to database:")
|
|
||||||
print("docker exec -it bodytrack-dev-db-1 psql -U bodytrack -d bodytrack")
|
|
||||||
print("\n-- Then paste migration content:")
|
|
||||||
print("-- (copy from backend/migrations/024_goal_type_registry.sql)")
|
|
||||||
print("-- Skip CREATE TABLE (already exists), run INSERT statements only")
|
|
||||||
print("```")
|
|
||||||
elif active_count >= 6:
|
|
||||||
print("\n🟢 EVERYTHING LOOKS GOOD")
|
|
||||||
print(f" {active_count} active goal types available")
|
|
||||||
print("\nIf frontend still shows error, check:")
|
|
||||||
print(" 1. Backend logs: docker logs bodytrack-dev-backend-1 -f")
|
|
||||||
print(" 2. Network tab in browser DevTools")
|
|
||||||
print(" 3. API endpoint: curl -H 'X-Auth-Token: YOUR_TOKEN' http://localhost:8099/api/goals/goal-types")
|
|
||||||
else:
|
|
||||||
print(f"\n🟡 PARTIAL DATA: {active_count} active types (expected 6)")
|
|
||||||
print(" Some INSERTs might have failed")
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
"""
|
|
||||||
Data Layer - Pure Data Retrieval & Calculation Logic
|
|
||||||
|
|
||||||
This module provides structured data functions for all metrics.
|
|
||||||
NO FORMATTING. NO STRINGS WITH UNITS. Only structured data.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from data_layer.body_metrics import get_weight_trend_data
|
|
||||||
|
|
||||||
data = get_weight_trend_data(profile_id="123", days=28)
|
|
||||||
# Returns: {"slope_28d": 0.23, "confidence": "high", ...}
|
|
||||||
|
|
||||||
Modules:
|
|
||||||
- body_metrics: Weight, body fat, lean mass, circumferences
|
|
||||||
- nutrition_metrics: Calories, protein, macros, adherence
|
|
||||||
- activity_metrics: Training volume, quality, abilities
|
|
||||||
- recovery_metrics: Sleep, RHR, HRV, recovery score
|
|
||||||
- health_metrics: Blood pressure, VO2Max, health stability
|
|
||||||
- goals: Active goals, progress, projections
|
|
||||||
- correlations: Lag-analysis, plateau detection
|
|
||||||
- utils: Shared functions (confidence, baseline, outliers)
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
Created: 2026-03-28
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Core utilities
|
|
||||||
from .utils import *
|
|
||||||
|
|
||||||
# Metric modules
|
|
||||||
from .body_metrics import *
|
|
||||||
from .nutrition_metrics import *
|
|
||||||
from .activity_metrics import *
|
|
||||||
from .recovery_metrics import *
|
|
||||||
from .health_metrics import *
|
|
||||||
from .scores import *
|
|
||||||
from .correlations import *
|
|
||||||
|
|
||||||
# Future imports (will be added as modules are created):
|
|
||||||
# from .goals import *
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Utils
|
|
||||||
'calculate_confidence',
|
|
||||||
'serialize_dates',
|
|
||||||
|
|
||||||
# Body Metrics (Basic)
|
|
||||||
'get_latest_weight_data',
|
|
||||||
'get_weight_trend_data',
|
|
||||||
'get_body_composition_data',
|
|
||||||
'get_circumference_summary_data',
|
|
||||||
|
|
||||||
# Body Metrics (Calculated)
|
|
||||||
'calculate_weight_7d_median',
|
|
||||||
'calculate_weight_28d_slope',
|
|
||||||
'calculate_weight_90d_slope',
|
|
||||||
'calculate_goal_projection_date',
|
|
||||||
'calculate_goal_progress_pct',
|
|
||||||
'calculate_fm_28d_change',
|
|
||||||
'calculate_lbm_28d_change',
|
|
||||||
'calculate_waist_28d_delta',
|
|
||||||
'calculate_hip_28d_delta',
|
|
||||||
'calculate_chest_28d_delta',
|
|
||||||
'calculate_arm_28d_delta',
|
|
||||||
'calculate_thigh_28d_delta',
|
|
||||||
'calculate_waist_hip_ratio',
|
|
||||||
'calculate_recomposition_quadrant',
|
|
||||||
'calculate_body_progress_score',
|
|
||||||
'calculate_body_data_quality',
|
|
||||||
|
|
||||||
# Nutrition Metrics (Basic)
|
|
||||||
'get_nutrition_average_data',
|
|
||||||
'get_nutrition_days_data',
|
|
||||||
'get_protein_targets_data',
|
|
||||||
'get_energy_balance_data',
|
|
||||||
'get_protein_adequacy_data',
|
|
||||||
'get_macro_consistency_data',
|
|
||||||
|
|
||||||
# Nutrition Metrics (Calculated)
|
|
||||||
'calculate_energy_balance_7d',
|
|
||||||
'calculate_energy_deficit_surplus',
|
|
||||||
'calculate_protein_g_per_kg',
|
|
||||||
'calculate_protein_days_in_target',
|
|
||||||
'calculate_protein_adequacy_28d',
|
|
||||||
'calculate_macro_consistency_score',
|
|
||||||
'calculate_intake_volatility',
|
|
||||||
'calculate_nutrition_score',
|
|
||||||
'calculate_energy_availability_warning',
|
|
||||||
'calculate_fiber_avg_7d',
|
|
||||||
'calculate_sugar_avg_7d',
|
|
||||||
'calculate_nutrition_data_quality',
|
|
||||||
|
|
||||||
# Activity Metrics (Basic)
|
|
||||||
'get_activity_summary_data',
|
|
||||||
'get_activity_detail_data',
|
|
||||||
'get_training_type_distribution_data',
|
|
||||||
|
|
||||||
# Activity Metrics (Calculated)
|
|
||||||
'calculate_training_minutes_week',
|
|
||||||
'calculate_training_frequency_7d',
|
|
||||||
'calculate_quality_sessions_pct',
|
|
||||||
'calculate_intensity_proxy_distribution',
|
|
||||||
'calculate_ability_balance',
|
|
||||||
'calculate_ability_balance_strength',
|
|
||||||
'calculate_ability_balance_endurance',
|
|
||||||
'calculate_ability_balance_mental',
|
|
||||||
'calculate_ability_balance_coordination',
|
|
||||||
'calculate_ability_balance_mobility',
|
|
||||||
'calculate_proxy_internal_load_7d',
|
|
||||||
'calculate_monotony_score',
|
|
||||||
'calculate_strain_score',
|
|
||||||
'calculate_activity_score',
|
|
||||||
'calculate_rest_day_compliance',
|
|
||||||
'calculate_vo2max_trend_28d',
|
|
||||||
'calculate_activity_data_quality',
|
|
||||||
|
|
||||||
# Recovery Metrics (Basic)
|
|
||||||
'get_sleep_duration_data',
|
|
||||||
'get_sleep_quality_data',
|
|
||||||
'get_rest_days_data',
|
|
||||||
|
|
||||||
# Recovery Metrics (Calculated)
|
|
||||||
'calculate_recovery_score_v2',
|
|
||||||
'calculate_hrv_vs_baseline_pct',
|
|
||||||
'calculate_rhr_vs_baseline_pct',
|
|
||||||
'calculate_sleep_avg_duration_7d',
|
|
||||||
'calculate_sleep_debt_hours',
|
|
||||||
'calculate_sleep_regularity_proxy',
|
|
||||||
'calculate_recent_load_balance_3d',
|
|
||||||
'calculate_sleep_quality_7d',
|
|
||||||
'calculate_recovery_data_quality',
|
|
||||||
|
|
||||||
# Health Metrics
|
|
||||||
'get_resting_heart_rate_data',
|
|
||||||
'get_heart_rate_variability_data',
|
|
||||||
'get_vo2_max_data',
|
|
||||||
|
|
||||||
# Scoring Metrics
|
|
||||||
'get_user_focus_weights',
|
|
||||||
'get_focus_area_category',
|
|
||||||
'map_focus_to_score_components',
|
|
||||||
'map_category_de_to_en',
|
|
||||||
'calculate_category_weight',
|
|
||||||
'calculate_goal_progress_score',
|
|
||||||
'calculate_health_stability_score',
|
|
||||||
'calculate_data_quality_score',
|
|
||||||
'get_top_priority_goal',
|
|
||||||
'get_top_focus_area',
|
|
||||||
'calculate_focus_area_progress',
|
|
||||||
'calculate_category_progress',
|
|
||||||
|
|
||||||
# Correlation Metrics
|
|
||||||
'calculate_lag_correlation',
|
|
||||||
'calculate_correlation_sleep_recovery',
|
|
||||||
'calculate_plateau_detected',
|
|
||||||
'calculate_top_drivers',
|
|
||||||
'calculate_correlation_confidence',
|
|
||||||
]
|
|
||||||
|
|
@ -1,906 +0,0 @@
|
||||||
"""
|
|
||||||
Activity Metrics Data Layer
|
|
||||||
|
|
||||||
Provides structured data for training tracking and analysis.
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- get_activity_summary_data(): Count, total duration, calories, averages
|
|
||||||
- get_activity_detail_data(): Detailed activity log entries
|
|
||||||
- get_training_type_distribution_data(): Training category percentages
|
|
||||||
|
|
||||||
All functions return structured data (dict) without formatting.
|
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
import statistics
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_summary_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 14
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get activity summary statistics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 14)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"activity_count": int,
|
|
||||||
"total_duration_min": int,
|
|
||||||
"total_kcal": int,
|
|
||||||
"avg_duration_min": int,
|
|
||||||
"avg_kcal_per_session": int,
|
|
||||||
"sessions_per_week": float,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_activity_summary(pid, days) formatted string
|
|
||||||
NEW: Structured data with all metrics
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
COUNT(*) as count,
|
|
||||||
SUM(duration_min) as total_min,
|
|
||||||
SUM(kcal_active) as total_kcal
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row or row['count'] == 0:
|
|
||||||
return {
|
|
||||||
"activity_count": 0,
|
|
||||||
"total_duration_min": 0,
|
|
||||||
"total_kcal": 0,
|
|
||||||
"avg_duration_min": 0,
|
|
||||||
"avg_kcal_per_session": 0,
|
|
||||||
"sessions_per_week": 0.0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
activity_count = row['count']
|
|
||||||
total_min = safe_int(row['total_min'])
|
|
||||||
total_kcal = safe_int(row['total_kcal'])
|
|
||||||
|
|
||||||
avg_duration = int(total_min / activity_count) if activity_count > 0 else 0
|
|
||||||
avg_kcal = int(total_kcal / activity_count) if activity_count > 0 else 0
|
|
||||||
sessions_per_week = (activity_count / days * 7) if days > 0 else 0.0
|
|
||||||
|
|
||||||
confidence = calculate_confidence(activity_count, days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"activity_count": activity_count,
|
|
||||||
"total_duration_min": total_min,
|
|
||||||
"total_kcal": total_kcal,
|
|
||||||
"avg_duration_min": avg_duration,
|
|
||||||
"avg_kcal_per_session": avg_kcal,
|
|
||||||
"sessions_per_week": round(sessions_per_week, 1),
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_detail_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 14,
|
|
||||||
limit: int = 50
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get detailed activity log entries.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 14)
|
|
||||||
limit: Maximum entries to return (default 50)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"activities": [
|
|
||||||
{
|
|
||||||
"date": date,
|
|
||||||
"activity_type": str,
|
|
||||||
"duration_min": int,
|
|
||||||
"kcal_active": int,
|
|
||||||
"hr_avg": int | None,
|
|
||||||
"training_category": str | None
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"total_count": int,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_activity_detail(pid, days) formatted string list
|
|
||||||
NEW: Structured array with all fields
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
date,
|
|
||||||
activity_type,
|
|
||||||
duration_min,
|
|
||||||
kcal_active,
|
|
||||||
hr_avg,
|
|
||||||
training_category
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT %s""",
|
|
||||||
(profile_id, cutoff, limit)
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"activities": [],
|
|
||||||
"total_count": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
activities = []
|
|
||||||
for row in rows:
|
|
||||||
activities.append({
|
|
||||||
"date": row['date'],
|
|
||||||
"activity_type": row['activity_type'],
|
|
||||||
"duration_min": safe_int(row['duration_min']),
|
|
||||||
"kcal_active": safe_int(row['kcal_active']),
|
|
||||||
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
|
|
||||||
"training_category": row.get('training_category')
|
|
||||||
})
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(activities), days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"activities": activities,
|
|
||||||
"total_count": len(activities),
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_type_distribution_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 14
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Calculate training category distribution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 14)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"distribution": [
|
|
||||||
{
|
|
||||||
"category": str,
|
|
||||||
"count": int,
|
|
||||||
"percentage": float
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"total_sessions": int,
|
|
||||||
"categorized_sessions": int,
|
|
||||||
"uncategorized_sessions": int,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_trainingstyp_verteilung(pid, days) top 3 formatted
|
|
||||||
NEW: Complete distribution with percentages
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Get categorized activities
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
training_category,
|
|
||||||
COUNT(*) as count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
AND date >= %s
|
|
||||||
AND training_category IS NOT NULL
|
|
||||||
GROUP BY training_category
|
|
||||||
ORDER BY count DESC""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
# Get total activity count (including uncategorized)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT COUNT(*) as total
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
total_row = cur.fetchone()
|
|
||||||
total_sessions = total_row['total'] if total_row else 0
|
|
||||||
|
|
||||||
if not rows or total_sessions == 0:
|
|
||||||
return {
|
|
||||||
"distribution": [],
|
|
||||||
"total_sessions": total_sessions,
|
|
||||||
"categorized_sessions": 0,
|
|
||||||
"uncategorized_sessions": total_sessions,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
categorized_count = sum(row['count'] for row in rows)
|
|
||||||
uncategorized_count = total_sessions - categorized_count
|
|
||||||
|
|
||||||
distribution = []
|
|
||||||
for row in rows:
|
|
||||||
count = row['count']
|
|
||||||
percentage = (count / total_sessions * 100) if total_sessions > 0 else 0
|
|
||||||
distribution.append({
|
|
||||||
"category": row['training_category'],
|
|
||||||
"count": count,
|
|
||||||
"percentage": round(percentage, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
confidence = calculate_confidence(categorized_count, days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"distribution": distribution,
|
|
||||||
"total_sessions": total_sessions,
|
|
||||||
"categorized_sessions": categorized_count,
|
|
||||||
"uncategorized_sessions": uncategorized_count,
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Calculated Metrics (migrated from calculations/activity_metrics.py)
|
|
||||||
# ============================================================================
|
|
||||||
# These functions return simple values for placeholders and scoring.
|
|
||||||
# Use get_*_data() functions above for structured chart data.
|
|
||||||
|
|
||||||
def calculate_training_minutes_week(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate total training minutes last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT SUM(duration_min) as total_minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return int(row['total_minutes']) if row and row['total_minutes'] else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate number of training sessions last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as session_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return int(row['session_count']) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate percentage of quality sessions (good or better) last 28 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row['total'] == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
pct = (row['quality_count'] / row['total']) * 100
|
|
||||||
return int(pct)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A2: Intensity Distribution (Proxy-based)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_intensity_proxy_distribution(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate intensity distribution (proxy until HR zones available)
|
|
||||||
Returns dict: {'low': X, 'moderate': Y, 'high': Z} in minutes
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_min, hr_avg, hr_max
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
low_min = 0
|
|
||||||
moderate_min = 0
|
|
||||||
high_min = 0
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration_min']
|
|
||||||
avg_hr = activity['hr_avg']
|
|
||||||
max_hr = activity['hr_max']
|
|
||||||
|
|
||||||
# Simple proxy classification
|
|
||||||
if avg_hr:
|
|
||||||
# Rough HR-based classification (assumes max HR ~190)
|
|
||||||
if avg_hr < 120:
|
|
||||||
low_min += duration
|
|
||||||
elif avg_hr < 150:
|
|
||||||
moderate_min += duration
|
|
||||||
else:
|
|
||||||
high_min += duration
|
|
||||||
else:
|
|
||||||
# Fallback: assume moderate
|
|
||||||
moderate_min += duration
|
|
||||||
|
|
||||||
return {
|
|
||||||
'low': low_min,
|
|
||||||
'moderate': moderate_min,
|
|
||||||
'high': high_min
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A4: Ability Balance Calculations
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_ability_balance(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate ability balance from training_types.abilities
|
|
||||||
Returns dict with scores per ability dimension (0-100)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT a.duration_min, tt.abilities
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_types tt ON a.training_category = tt.category
|
|
||||||
WHERE a.profile_id = %s
|
|
||||||
AND a.date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND tt.abilities IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Accumulate ability load (duration × ability weight)
|
|
||||||
ability_loads = {
|
|
||||||
'strength': 0,
|
|
||||||
'endurance': 0,
|
|
||||||
'mental': 0,
|
|
||||||
'coordination': 0,
|
|
||||||
'mobility': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration_min']
|
|
||||||
abilities = activity['abilities'] # JSONB
|
|
||||||
|
|
||||||
if not abilities:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for ability, weight in abilities.items():
|
|
||||||
if ability in ability_loads:
|
|
||||||
ability_loads[ability] += duration * weight
|
|
||||||
|
|
||||||
# Normalize to 0-100 scale
|
|
||||||
max_load = max(ability_loads.values()) if ability_loads else 1
|
|
||||||
if max_load == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
normalized = {
|
|
||||||
ability: int((load / max_load) * 100)
|
|
||||||
for ability, load in ability_loads.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_strength(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get strength ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['strength'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_endurance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get endurance ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['endurance'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_mental(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get mental ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['mental'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_coordination(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get coordination ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['coordination'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
|
||||||
"""Get mobility ability score"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
return balance['mobility'] if balance else None
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A5: Load Monitoring (Proxy-based)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate proxy internal load (last 7 days)
|
|
||||||
Formula: duration × intensity_factor × quality_factor
|
|
||||||
"""
|
|
||||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
|
||||||
quality_factors = {
|
|
||||||
'excellent': 1.15,
|
|
||||||
'very_good': 1.05,
|
|
||||||
'good': 1.0,
|
|
||||||
'acceptable': 0.9,
|
|
||||||
'poor': 0.75,
|
|
||||||
'excluded': 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_min, hr_avg, rpe
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
if not activities:
|
|
||||||
return None
|
|
||||||
|
|
||||||
total_load = 0
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
duration = activity['duration_min']
|
|
||||||
avg_hr = activity['hr_avg']
|
|
||||||
# Map RPE to quality (rpe 8-10 = excellent, 6-7 = good, 4-5 = moderate, <4 = poor)
|
|
||||||
rpe = activity.get('rpe')
|
|
||||||
if rpe and rpe >= 8:
|
|
||||||
quality = 'excellent'
|
|
||||||
elif rpe and rpe >= 6:
|
|
||||||
quality = 'good'
|
|
||||||
elif rpe and rpe >= 4:
|
|
||||||
quality = 'moderate'
|
|
||||||
else:
|
|
||||||
quality = 'good' # default
|
|
||||||
|
|
||||||
# Determine intensity
|
|
||||||
if avg_hr:
|
|
||||||
if avg_hr < 120:
|
|
||||||
intensity = 'low'
|
|
||||||
elif avg_hr < 150:
|
|
||||||
intensity = 'moderate'
|
|
||||||
else:
|
|
||||||
intensity = 'high'
|
|
||||||
else:
|
|
||||||
intensity = 'moderate'
|
|
||||||
|
|
||||||
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
|
||||||
total_load += load
|
|
||||||
|
|
||||||
return int(total_load)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate training monotony (last 7 days)
|
|
||||||
Monotony = mean daily load / std dev daily load
|
|
||||||
Higher = more monotonous
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, SUM(duration_min) as daily_duration
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
daily_loads = [float(row['daily_duration']) for row in cur.fetchall() if row['daily_duration']]
|
|
||||||
|
|
||||||
if len(daily_loads) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
mean_load = sum(daily_loads) / len(daily_loads)
|
|
||||||
std_dev = statistics.stdev(daily_loads)
|
|
||||||
|
|
||||||
if std_dev == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monotony = mean_load / std_dev
|
|
||||||
return round(monotony, 2)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_strain_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate training strain (last 7 days)
|
|
||||||
Strain = weekly load × monotony
|
|
||||||
"""
|
|
||||||
weekly_load = calculate_proxy_internal_load_7d(profile_id)
|
|
||||||
monotony = calculate_monotony_score(profile_id)
|
|
||||||
|
|
||||||
if weekly_load is None or monotony is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
strain = weekly_load * monotony
|
|
||||||
return int(strain)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A6: Activity Goal Alignment Score (Dynamic Focus Areas)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Activity goal alignment score 0-100
|
|
||||||
Weighted by user's activity-related focus areas
|
|
||||||
"""
|
|
||||||
if focus_weights is None:
|
|
||||||
from data_layer.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
# Activity-related focus areas (English keys from DB)
|
|
||||||
# Strength training
|
|
||||||
strength = focus_weights.get('strength', 0)
|
|
||||||
strength_endurance = focus_weights.get('strength_endurance', 0)
|
|
||||||
power = focus_weights.get('power', 0)
|
|
||||||
total_strength = strength + strength_endurance + power
|
|
||||||
|
|
||||||
# Endurance training
|
|
||||||
aerobic = focus_weights.get('aerobic_endurance', 0)
|
|
||||||
anaerobic = focus_weights.get('anaerobic_endurance', 0)
|
|
||||||
cardiovascular = focus_weights.get('cardiovascular_health', 0)
|
|
||||||
total_cardio = aerobic + anaerobic + cardiovascular
|
|
||||||
|
|
||||||
# Mobility/Coordination
|
|
||||||
flexibility = focus_weights.get('flexibility', 0)
|
|
||||||
mobility = focus_weights.get('mobility', 0)
|
|
||||||
balance = focus_weights.get('balance', 0)
|
|
||||||
reaction = focus_weights.get('reaction', 0)
|
|
||||||
rhythm = focus_weights.get('rhythm', 0)
|
|
||||||
coordination = focus_weights.get('coordination', 0)
|
|
||||||
total_ability = flexibility + mobility + balance + reaction + rhythm + coordination
|
|
||||||
|
|
||||||
total_activity_weight = total_strength + total_cardio + total_ability
|
|
||||||
|
|
||||||
if total_activity_weight == 0:
|
|
||||||
return None # No activity goals
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Weekly minutes (general activity volume)
|
|
||||||
minutes = calculate_training_minutes_week(profile_id)
|
|
||||||
if minutes is not None:
|
|
||||||
# WHO: 150-300 min/week
|
|
||||||
if 150 <= minutes <= 300:
|
|
||||||
minutes_score = 100
|
|
||||||
elif minutes < 150:
|
|
||||||
minutes_score = max(40, (minutes / 150) * 100)
|
|
||||||
else:
|
|
||||||
minutes_score = max(80, 100 - ((minutes - 300) / 10))
|
|
||||||
|
|
||||||
# Volume relevant for all activity types (20% base weight)
|
|
||||||
components.append(('minutes', minutes_score, total_activity_weight * 0.2))
|
|
||||||
|
|
||||||
# 2. Quality sessions (always relevant)
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id)
|
|
||||||
if quality_pct is not None:
|
|
||||||
# Quality gets 10% base weight
|
|
||||||
components.append(('quality', quality_pct, total_activity_weight * 0.1))
|
|
||||||
|
|
||||||
# 3. Strength presence (if strength focus active)
|
|
||||||
if total_strength > 0:
|
|
||||||
strength_score = _score_strength_presence(profile_id)
|
|
||||||
if strength_score is not None:
|
|
||||||
components.append(('strength', strength_score, total_strength))
|
|
||||||
|
|
||||||
# 4. Cardio presence (if cardio focus active)
|
|
||||||
if total_cardio > 0:
|
|
||||||
cardio_score = _score_cardio_presence(profile_id)
|
|
||||||
if cardio_score is not None:
|
|
||||||
components.append(('cardio', cardio_score, total_cardio))
|
|
||||||
|
|
||||||
# 5. Ability balance (if mobility/coordination focus active)
|
|
||||||
if total_ability > 0:
|
|
||||||
balance_score = _score_ability_balance(profile_id)
|
|
||||||
if balance_score is not None:
|
|
||||||
components.append(('balance', balance_score, total_ability))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_strength_presence(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score strength training presence (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(DISTINCT date) as strength_days
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND training_category = 'strength'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
strength_days = row['strength_days']
|
|
||||||
|
|
||||||
# Target: 2-4 days/week
|
|
||||||
if 2 <= strength_days <= 4:
|
|
||||||
return 100
|
|
||||||
elif strength_days == 1:
|
|
||||||
return 60
|
|
||||||
elif strength_days == 5:
|
|
||||||
return 85
|
|
||||||
elif strength_days == 0:
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
return 70
|
|
||||||
|
|
||||||
|
|
||||||
def _score_cardio_presence(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score cardio training presence (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(DISTINCT date) as cardio_days, SUM(duration_min) as cardio_minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND training_category = 'cardio'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cardio_days = row['cardio_days']
|
|
||||||
cardio_minutes = row['cardio_minutes'] or 0
|
|
||||||
|
|
||||||
# Target: 3-5 days/week, 150+ minutes
|
|
||||||
day_score = min(100, (cardio_days / 4) * 100)
|
|
||||||
minute_score = min(100, (cardio_minutes / 150) * 100)
|
|
||||||
|
|
||||||
return int((day_score + minute_score) / 2)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_ability_balance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score ability balance (0-100)"""
|
|
||||||
balance = calculate_ability_balance(profile_id)
|
|
||||||
|
|
||||||
if not balance:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Good balance = all abilities > 40, std_dev < 30
|
|
||||||
values = list(balance.values())
|
|
||||||
min_value = min(values)
|
|
||||||
std_dev = statistics.stdev(values) if len(values) > 1 else 0
|
|
||||||
|
|
||||||
# Score based on minimum coverage and balance
|
|
||||||
min_score = min(100, min_value * 2) # Want all > 50
|
|
||||||
balance_score = max(0, 100 - (std_dev * 2)) # Want low std_dev
|
|
||||||
|
|
||||||
return int((min_score + balance_score) / 2)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A7: Rest Day Compliance
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_rest_day_compliance(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate rest day compliance percentage (last 28 days)
|
|
||||||
Returns percentage of planned rest days that were respected
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get planned rest days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, rest_config->>'focus' as rest_type
|
|
||||||
FROM rest_days
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
rest_days = {row['date']: row['rest_type'] for row in cur.fetchall()}
|
|
||||||
|
|
||||||
if not rest_days:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if training occurred on rest days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, training_category
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
training_days = {}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
if row['date'] not in training_days:
|
|
||||||
training_days[row['date']] = []
|
|
||||||
training_days[row['date']].append(row['training_category'])
|
|
||||||
|
|
||||||
# Count compliance
|
|
||||||
compliant = 0
|
|
||||||
total = len(rest_days)
|
|
||||||
|
|
||||||
for rest_date, rest_type in rest_days.items():
|
|
||||||
if rest_date not in training_days:
|
|
||||||
# Full rest = compliant
|
|
||||||
compliant += 1
|
|
||||||
else:
|
|
||||||
# Check if training violates rest type
|
|
||||||
categories = training_days[rest_date]
|
|
||||||
if rest_type == 'strength_rest' and 'strength' not in categories:
|
|
||||||
compliant += 1
|
|
||||||
elif rest_type == 'cardio_rest' and 'cardio' not in categories:
|
|
||||||
compliant += 1
|
|
||||||
# If rest_type == 'recovery', any training = non-compliant
|
|
||||||
|
|
||||||
compliance_pct = (compliant / total) * 100
|
|
||||||
return int(compliance_pct)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# A8: VO2max Development
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_vo2max_trend_28d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate VO2max trend (change over 28 days)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT vo2_max, date
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND vo2_max IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
measurements = cur.fetchall()
|
|
||||||
|
|
||||||
if len(measurements) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = measurements[0]['vo2_max']
|
|
||||||
oldest = measurements[-1]['vo2_max']
|
|
||||||
|
|
||||||
change = recent - oldest
|
|
||||||
return round(change, 1)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for activity metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Activity entries last 28 days
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as total,
|
|
||||||
COUNT(hr_avg) as with_hr,
|
|
||||||
COUNT(rpe) as with_quality
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
counts = cur.fetchone()
|
|
||||||
|
|
||||||
total_entries = counts['total']
|
|
||||||
hr_coverage = counts['with_hr'] / total_entries if total_entries > 0 else 0
|
|
||||||
quality_coverage = counts['with_quality'] / total_entries if total_entries > 0 else 0
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
frequency_score = min(100, (total_entries / 15) * 100) # 15 = ~4 sessions/week
|
|
||||||
hr_score = hr_coverage * 100
|
|
||||||
quality_score = quality_coverage * 100
|
|
||||||
|
|
||||||
# Overall score
|
|
||||||
overall_score = int(
|
|
||||||
frequency_score * 0.5 +
|
|
||||||
hr_score * 0.25 +
|
|
||||||
quality_score * 0.25
|
|
||||||
)
|
|
||||||
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"activities_28d": total_entries,
|
|
||||||
"hr_coverage_pct": int(hr_coverage * 100),
|
|
||||||
"quality_coverage_pct": int(quality_coverage * 100)
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"frequency": int(frequency_score),
|
|
||||||
"hr": int(hr_score),
|
|
||||||
"quality": int(quality_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,830 +0,0 @@
|
||||||
"""
|
|
||||||
Body Metrics Data Layer
|
|
||||||
|
|
||||||
Provides structured data for body composition and measurements.
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- get_latest_weight_data(): Most recent weight entry
|
|
||||||
- get_weight_trend_data(): Weight trend with slope and direction
|
|
||||||
- get_body_composition_data(): Body fat percentage and lean mass
|
|
||||||
- get_circumference_summary_data(): Latest circumference measurements
|
|
||||||
|
|
||||||
All functions return structured data (dict) without formatting.
|
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
import statistics
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_weight_data(
|
|
||||||
profile_id: str
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get most recent weight entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"weight": float, # kg
|
|
||||||
"date": date,
|
|
||||||
"confidence": str
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_latest_weight() returned formatted string "85.0 kg"
|
|
||||||
NEW: Returns structured data {"weight": 85.0, "date": ...}
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT weight, date FROM weight_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(profile_id,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return {
|
|
||||||
"weight": 0.0,
|
|
||||||
"date": None,
|
|
||||||
"confidence": "insufficient"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"weight": safe_float(row['weight']),
|
|
||||||
"date": row['date'],
|
|
||||||
"confidence": "high"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_weight_trend_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 28
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Calculate weight trend with slope and direction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 28)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"first_value": float,
|
|
||||||
"last_value": float,
|
|
||||||
"delta": float, # kg change
|
|
||||||
"direction": str, # "increasing" | "decreasing" | "stable"
|
|
||||||
"data_points": int,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int,
|
|
||||||
"first_date": date,
|
|
||||||
"last_date": date
|
|
||||||
}
|
|
||||||
|
|
||||||
Confidence Rules:
|
|
||||||
- high: >= 18 points (28d) or >= 4 points (7d)
|
|
||||||
- medium: >= 12 points (28d) or >= 3 points (7d)
|
|
||||||
- low: >= 8 points (28d) or >= 2 points (7d)
|
|
||||||
- insufficient: < thresholds
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_weight_trend() returned formatted string
|
|
||||||
NEW: Returns structured data for reuse in charts + AI
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT weight, date FROM weight_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Calculate confidence
|
|
||||||
confidence = calculate_confidence(len(rows), days, "general")
|
|
||||||
|
|
||||||
# Early return if insufficient
|
|
||||||
if confidence == 'insufficient' or len(rows) < 2:
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows),
|
|
||||||
"days_analyzed": days,
|
|
||||||
"first_value": 0.0,
|
|
||||||
"last_value": 0.0,
|
|
||||||
"delta": 0.0,
|
|
||||||
"direction": "unknown",
|
|
||||||
"first_date": None,
|
|
||||||
"last_date": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract values
|
|
||||||
first_value = safe_float(rows[0]['weight'])
|
|
||||||
last_value = safe_float(rows[-1]['weight'])
|
|
||||||
delta = last_value - first_value
|
|
||||||
|
|
||||||
# Determine direction
|
|
||||||
if abs(delta) < 0.3:
|
|
||||||
direction = "stable"
|
|
||||||
elif delta > 0:
|
|
||||||
direction = "increasing"
|
|
||||||
else:
|
|
||||||
direction = "decreasing"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"first_value": first_value,
|
|
||||||
"last_value": last_value,
|
|
||||||
"delta": delta,
|
|
||||||
"direction": direction,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days,
|
|
||||||
"first_date": rows[0]['date'],
|
|
||||||
"last_date": rows[-1]['date']
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_body_composition_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 90
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get latest body composition data (body fat, lean mass).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Lookback window (default 90)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"body_fat_pct": float,
|
|
||||||
"method": str, # "jackson_pollock" | "durnin_womersley" | etc.
|
|
||||||
"date": date,
|
|
||||||
"confidence": str,
|
|
||||||
"data_points": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_latest_bf() returned formatted string "15.2%"
|
|
||||||
NEW: Returns structured data {"body_fat_pct": 15.2, ...}
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT body_fat_pct, sf_method, date
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
AND body_fat_pct IS NOT NULL
|
|
||||||
AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
row = r2d(cur.fetchone()) if cur.rowcount > 0 else None
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"body_fat_pct": 0.0,
|
|
||||||
"method": None,
|
|
||||||
"date": None
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"body_fat_pct": safe_float(row['body_fat_pct']),
|
|
||||||
"method": row.get('sf_method', 'unknown'),
|
|
||||||
"date": row['date'],
|
|
||||||
"confidence": "high", # Latest measurement is always high confidence
|
|
||||||
"data_points": 1
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_circumference_summary_data(
|
|
||||||
profile_id: str,
|
|
||||||
max_age_days: int = 90
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get latest circumference measurements for all body points.
|
|
||||||
|
|
||||||
For each measurement point, fetches the most recent value (even if from different dates).
|
|
||||||
Returns measurements with age in days for each point.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
max_age_days: Maximum age of measurements to include (default 90)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"measurements": [
|
|
||||||
{
|
|
||||||
"point": str, # "Nacken", "Brust", etc.
|
|
||||||
"field": str, # "c_neck", "c_chest", etc.
|
|
||||||
"value": float, # cm
|
|
||||||
"date": date,
|
|
||||||
"age_days": int
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"confidence": str,
|
|
||||||
"data_points": int,
|
|
||||||
"newest_date": date,
|
|
||||||
"oldest_date": date
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_circ_summary() returned formatted string "Nacken 38.0cm (vor 2 Tagen), ..."
|
|
||||||
NEW: Returns structured array for charts + AI formatting
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Define all circumference points
|
|
||||||
fields = [
|
|
||||||
('c_neck', 'Nacken'),
|
|
||||||
('c_chest', 'Brust'),
|
|
||||||
('c_waist', 'Taille'),
|
|
||||||
('c_belly', 'Bauch'),
|
|
||||||
('c_hip', 'Hüfte'),
|
|
||||||
('c_thigh', 'Oberschenkel'),
|
|
||||||
('c_calf', 'Wade'),
|
|
||||||
('c_arm', 'Arm')
|
|
||||||
]
|
|
||||||
|
|
||||||
measurements = []
|
|
||||||
today = datetime.now().date()
|
|
||||||
|
|
||||||
# Get latest value for each field individually
|
|
||||||
for field_name, label in fields:
|
|
||||||
cur.execute(
|
|
||||||
f"""SELECT {field_name}, date,
|
|
||||||
CURRENT_DATE - date AS age_days
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
AND {field_name} IS NOT NULL
|
|
||||||
AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(profile_id, (today - timedelta(days=max_age_days)).isoformat())
|
|
||||||
)
|
|
||||||
row = r2d(cur.fetchone()) if cur.rowcount > 0 else None
|
|
||||||
|
|
||||||
if row:
|
|
||||||
measurements.append({
|
|
||||||
"point": label,
|
|
||||||
"field": field_name,
|
|
||||||
"value": safe_float(row[field_name]),
|
|
||||||
"date": row['date'],
|
|
||||||
"age_days": row['age_days']
|
|
||||||
})
|
|
||||||
|
|
||||||
# Calculate confidence based on how many points we have
|
|
||||||
confidence = calculate_confidence(len(measurements), 8, "general")
|
|
||||||
|
|
||||||
if not measurements:
|
|
||||||
return {
|
|
||||||
"measurements": [],
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"newest_date": None,
|
|
||||||
"oldest_date": None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Find newest and oldest dates
|
|
||||||
dates = [m['date'] for m in measurements]
|
|
||||||
newest_date = max(dates)
|
|
||||||
oldest_date = min(dates)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"measurements": measurements,
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(measurements),
|
|
||||||
"newest_date": newest_date,
|
|
||||||
"oldest_date": oldest_date
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Calculated Metrics (migrated from calculations/body_metrics.py)
|
|
||||||
# Phase 0c: Single Source of Truth for KI + Charts
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# ── Weight Trend Calculations ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def calculate_weight_7d_median(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 7-day median weight (reduces daily noise)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
weights = [row['weight'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(weights) < 4: # Need at least 4 measurements
|
|
||||||
return None
|
|
||||||
|
|
||||||
return round(statistics.median(weights), 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_weight_28d_slope(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day weight slope (kg/day)"""
|
|
||||||
return _calculate_weight_slope(profile_id, days=28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_weight_90d_slope(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 90-day weight slope (kg/day)"""
|
|
||||||
return _calculate_weight_slope(profile_id, days=90)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate weight slope using linear regression
|
|
||||||
Returns kg/day (negative = weight loss)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT date, weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
data = [(row['date'], row['weight']) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Need minimum data points based on period
|
|
||||||
min_points = max(18, int(days * 0.6)) # 60% coverage
|
|
||||||
if len(data) < min_points:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Convert dates to days since start
|
|
||||||
start_date = data[0][0]
|
|
||||||
x_values = [(date - start_date).days for date, _ in data]
|
|
||||||
y_values = [weight for _, weight in data]
|
|
||||||
|
|
||||||
# Linear regression
|
|
||||||
n = len(data)
|
|
||||||
x_mean = sum(x_values) / n
|
|
||||||
y_mean = sum(y_values) / n
|
|
||||||
|
|
||||||
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
|
||||||
denominator = sum((x - x_mean) ** 2 for x in x_values)
|
|
||||||
|
|
||||||
if denominator == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
slope = numerator / denominator
|
|
||||||
return round(slope, 4) # kg/day
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Calculate projected date to reach goal based on 28d trend
|
|
||||||
Returns ISO date string or None if unrealistic
|
|
||||||
"""
|
|
||||||
from goal_utils import get_goal_by_id
|
|
||||||
|
|
||||||
goal = get_goal_by_id(goal_id)
|
|
||||||
if not goal or goal['goal_type'] != 'weight':
|
|
||||||
return None
|
|
||||||
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
if not slope or slope == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
current = goal['current_value']
|
|
||||||
target = goal['target_value']
|
|
||||||
remaining = target - current
|
|
||||||
|
|
||||||
days_needed = remaining / slope
|
|
||||||
|
|
||||||
# Unrealistic if >2 years or negative
|
|
||||||
if days_needed < 0 or days_needed > 730:
|
|
||||||
return None
|
|
||||||
|
|
||||||
projection_date = datetime.now().date() + timedelta(days=int(days_needed))
|
|
||||||
return projection_date.isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_goal_progress_pct(current: float, target: float, start: float) -> int:
|
|
||||||
"""
|
|
||||||
Calculate goal progress percentage
|
|
||||||
Returns 0-100 (can exceed 100 if target surpassed)
|
|
||||||
"""
|
|
||||||
if start == target:
|
|
||||||
return 100 if current == target else 0
|
|
||||||
|
|
||||||
progress = ((current - start) / (target - start)) * 100
|
|
||||||
return max(0, min(100, int(progress)))
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fat Mass / Lean Mass Calculations ───────────────────────────────────────
|
|
||||||
|
|
||||||
def calculate_fm_28d_change(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day fat mass change (kg)"""
|
|
||||||
return _calculate_body_composition_change(profile_id, 'fm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_lbm_28d_change(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day lean body mass change (kg)"""
|
|
||||||
return _calculate_body_composition_change(profile_id, 'lbm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_body_composition_change(profile_id: str, metric: str, days: int) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate change in body composition over period
|
|
||||||
metric: 'fm' (fat mass) or 'lbm' (lean mass)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get weight and caliper measurements
|
|
||||||
cur.execute("""
|
|
||||||
SELECT w.date, w.weight, c.body_fat_pct
|
|
||||||
FROM weight_log w
|
|
||||||
LEFT JOIN caliper_log c ON w.profile_id = c.profile_id
|
|
||||||
AND w.date = c.date
|
|
||||||
WHERE w.profile_id = %s
|
|
||||||
AND w.date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
ORDER BY w.date DESC
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
data = [
|
|
||||||
{
|
|
||||||
'date': row['date'],
|
|
||||||
'weight': row['weight'],
|
|
||||||
'bf_pct': row['body_fat_pct']
|
|
||||||
}
|
|
||||||
for row in cur.fetchall()
|
|
||||||
if row['body_fat_pct'] is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(data) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Most recent and oldest measurement
|
|
||||||
recent = data[0]
|
|
||||||
oldest = data[-1]
|
|
||||||
|
|
||||||
# Calculate FM and LBM
|
|
||||||
recent_fm = recent['weight'] * (recent['bf_pct'] / 100)
|
|
||||||
recent_lbm = recent['weight'] - recent_fm
|
|
||||||
|
|
||||||
oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100)
|
|
||||||
oldest_lbm = oldest['weight'] - oldest_fm
|
|
||||||
|
|
||||||
if metric == 'fm':
|
|
||||||
change = recent_fm - oldest_fm
|
|
||||||
else:
|
|
||||||
change = recent_lbm - oldest_lbm
|
|
||||||
|
|
||||||
return round(change, 2)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Circumference Calculations ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def calculate_waist_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day waist circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_waist', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_hip_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day hip circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_hip', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day chest circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_chest', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day arm circumference change (cm)"""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate 28-day thigh circumference change (cm)"""
|
|
||||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
|
||||||
if delta is None:
|
|
||||||
return None
|
|
||||||
return round(delta, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_circumference_delta(profile_id: str, column: str, days: int) -> Optional[float]:
|
|
||||||
"""Calculate change in circumference measurement"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT {column}
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
AND {column} IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
recent = cur.fetchone()
|
|
||||||
if not recent:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT {column}
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '%s days'
|
|
||||||
AND {column} IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id, days))
|
|
||||||
|
|
||||||
oldest = cur.fetchone()
|
|
||||||
if not oldest:
|
|
||||||
return None
|
|
||||||
|
|
||||||
change = recent[column] - oldest[column]
|
|
||||||
return round(change, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_waist_hip_ratio(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate current waist-to-hip ratio"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c_waist, c_hip
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND c_waist IS NOT NULL
|
|
||||||
AND c_hip IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ratio = row['c_waist'] / row['c_hip']
|
|
||||||
return round(ratio, 3)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Recomposition Detector ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def calculate_recomposition_quadrant(profile_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Determine recomposition quadrant based on 28d changes:
|
|
||||||
- optimal: FM down, LBM up
|
|
||||||
- cut_with_risk: FM down, LBM down
|
|
||||||
- bulk: FM up, LBM up
|
|
||||||
- unfavorable: FM up, LBM down
|
|
||||||
"""
|
|
||||||
fm_change = calculate_fm_28d_change(profile_id)
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
|
|
||||||
if fm_change is None or lbm_change is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if fm_change < 0 and lbm_change > 0:
|
|
||||||
return "optimal"
|
|
||||||
elif fm_change < 0 and lbm_change < 0:
|
|
||||||
return "cut_with_risk"
|
|
||||||
elif fm_change > 0 and lbm_change > 0:
|
|
||||||
return "bulk"
|
|
||||||
else:
|
|
||||||
return "unfavorable"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Body Progress Score ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict] = None) -> Optional[int]:
|
|
||||||
"""Calculate body progress score (0-100) weighted by user's focus areas"""
|
|
||||||
if focus_weights is None:
|
|
||||||
from data_layer.scores import get_user_focus_weights
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
weight_loss = focus_weights.get('weight_loss', 0)
|
|
||||||
muscle_gain = focus_weights.get('muscle_gain', 0)
|
|
||||||
body_recomp = focus_weights.get('body_recomposition', 0)
|
|
||||||
|
|
||||||
total_body_weight = weight_loss + muscle_gain + body_recomp
|
|
||||||
|
|
||||||
if total_body_weight == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
if weight_loss > 0:
|
|
||||||
weight_score = _score_weight_trend(profile_id)
|
|
||||||
if weight_score is not None:
|
|
||||||
components.append(('weight', weight_score, weight_loss))
|
|
||||||
|
|
||||||
if muscle_gain > 0 or body_recomp > 0:
|
|
||||||
comp_score = _score_body_composition(profile_id)
|
|
||||||
if comp_score is not None:
|
|
||||||
components.append(('composition', comp_score, muscle_gain + body_recomp))
|
|
||||||
|
|
||||||
waist_score = _score_waist_trend(profile_id)
|
|
||||||
if waist_score is not None:
|
|
||||||
waist_weight = 20 + (weight_loss * 0.3)
|
|
||||||
components.append(('waist', waist_score, waist_weight))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_weight_trend(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score weight trend alignment with goals (0-100)"""
|
|
||||||
from goal_utils import get_active_goals
|
|
||||||
|
|
||||||
goals = get_active_goals(profile_id)
|
|
||||||
weight_goals = [g for g in goals if g.get('goal_type') == 'weight']
|
|
||||||
if not weight_goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
goal = next((g for g in weight_goals if g.get('is_primary')), weight_goals[0])
|
|
||||||
|
|
||||||
current = goal.get('current_value')
|
|
||||||
target = goal.get('target_value')
|
|
||||||
start = goal.get('start_value')
|
|
||||||
|
|
||||||
if None in [current, target]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
current = float(current)
|
|
||||||
target = float(target)
|
|
||||||
|
|
||||||
if start is None:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '90 days'
|
|
||||||
ORDER BY date ASC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
start = float(row['weight']) if row else current
|
|
||||||
else:
|
|
||||||
start = float(start)
|
|
||||||
|
|
||||||
progress_pct = calculate_goal_progress_pct(current, target, start)
|
|
||||||
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
if slope is not None:
|
|
||||||
desired_direction = -1 if target < start else 1
|
|
||||||
actual_direction = -1 if slope < 0 else 1
|
|
||||||
|
|
||||||
if desired_direction == actual_direction:
|
|
||||||
score = min(100, progress_pct + 10)
|
|
||||||
else:
|
|
||||||
score = max(0, progress_pct - 20)
|
|
||||||
else:
|
|
||||||
score = progress_pct
|
|
||||||
|
|
||||||
return int(score)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_body_composition(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score body composition changes (0-100)"""
|
|
||||||
fm_change = calculate_fm_28d_change(profile_id)
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
|
|
||||||
if fm_change is None or lbm_change is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
quadrant = calculate_recomposition_quadrant(profile_id)
|
|
||||||
|
|
||||||
if quadrant == "optimal":
|
|
||||||
return 100
|
|
||||||
elif quadrant == "cut_with_risk":
|
|
||||||
penalty = min(30, abs(lbm_change) * 15)
|
|
||||||
return max(50, 80 - int(penalty))
|
|
||||||
elif quadrant == "bulk":
|
|
||||||
if lbm_change > 0 and fm_change > 0:
|
|
||||||
ratio = lbm_change / fm_change
|
|
||||||
if ratio >= 3:
|
|
||||||
return 90
|
|
||||||
elif ratio >= 2:
|
|
||||||
return 75
|
|
||||||
elif ratio >= 1:
|
|
||||||
return 60
|
|
||||||
else:
|
|
||||||
return 45
|
|
||||||
return 60
|
|
||||||
else:
|
|
||||||
return 20
|
|
||||||
|
|
||||||
|
|
||||||
def _score_waist_trend(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score waist circumference trend (0-100)"""
|
|
||||||
delta = calculate_waist_28d_delta(profile_id)
|
|
||||||
|
|
||||||
if delta is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if delta <= -3:
|
|
||||||
return 100
|
|
||||||
elif delta <= -2:
|
|
||||||
return 90
|
|
||||||
elif delta <= -1:
|
|
||||||
return 80
|
|
||||||
elif delta <= 0:
|
|
||||||
return 70
|
|
||||||
elif delta <= 1:
|
|
||||||
return 55
|
|
||||||
elif delta <= 2:
|
|
||||||
return 40
|
|
||||||
else:
|
|
||||||
return 20
|
|
||||||
|
|
||||||
|
|
||||||
# ── Data Quality Assessment ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def calculate_body_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""Assess data quality for body metrics"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
weight_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
caliper_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
circ_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
weight_score = min(100, (weight_count / 18) * 100)
|
|
||||||
caliper_score = min(100, (caliper_count / 4) * 100)
|
|
||||||
circ_score = min(100, (circ_count / 4) * 100)
|
|
||||||
|
|
||||||
overall_score = int(
|
|
||||||
weight_score * 0.5 +
|
|
||||||
caliper_score * 0.3 +
|
|
||||||
circ_score * 0.2
|
|
||||||
)
|
|
||||||
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"weight_28d": weight_count,
|
|
||||||
"caliper_28d": caliper_count,
|
|
||||||
"circumference_28d": circ_count
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"weight": int(weight_score),
|
|
||||||
"caliper": int(caliper_score),
|
|
||||||
"circumference": int(circ_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,503 +0,0 @@
|
||||||
"""
|
|
||||||
Correlation Metrics Data Layer
|
|
||||||
|
|
||||||
Provides structured correlation analysis and plateau detection functions.
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- calculate_lag_correlation(): Lag correlation between variables
|
|
||||||
- calculate_correlation_sleep_recovery(): Sleep-recovery correlation
|
|
||||||
- calculate_plateau_detected(): Plateau detection (weight, strength, endurance)
|
|
||||||
- calculate_top_drivers(): Top drivers for current goals
|
|
||||||
- calculate_correlation_confidence(): Confidence level for correlations
|
|
||||||
|
|
||||||
All functions return structured data (dict) or simple values.
|
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Calculate lagged correlation between two variables
|
|
||||||
|
|
||||||
Args:
|
|
||||||
var1: 'energy', 'protein', 'training_load'
|
|
||||||
var2: 'weight', 'lbm', 'hrv', 'rhr'
|
|
||||||
max_lag_days: Maximum lag to test
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'best_lag': X, # days
|
|
||||||
'correlation': 0.XX, # -1 to 1
|
|
||||||
'direction': 'positive'/'negative'/'none',
|
|
||||||
'confidence': 'high'/'medium'/'low',
|
|
||||||
'data_points': N
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if var1 == 'energy' and var2 == 'weight':
|
|
||||||
return _correlate_energy_weight(profile_id, max_lag_days)
|
|
||||||
elif var1 == 'protein' and var2 == 'lbm':
|
|
||||||
return _correlate_protein_lbm(profile_id, max_lag_days)
|
|
||||||
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
|
|
||||||
return _correlate_load_vitals(profile_id, var2, max_lag_days)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate energy balance with weight change
|
|
||||||
Test lags: 0, 3, 7, 10, 14 days
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get energy balance data (daily calories - estimated TDEE)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT n.date, n.kcal, w.weight
|
|
||||||
FROM nutrition_log n
|
|
||||||
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
|
|
||||||
AND w.date = n.date
|
|
||||||
WHERE n.profile_id = %s
|
|
||||||
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
|
|
||||||
ORDER BY n.date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(data) < 30:
|
|
||||||
return {
|
|
||||||
'best_lag': None,
|
|
||||||
'correlation': None,
|
|
||||||
'direction': 'none',
|
|
||||||
'confidence': 'low',
|
|
||||||
'data_points': len(data),
|
|
||||||
'reason': 'Insufficient data (<30 days)'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate 7d rolling energy balance
|
|
||||||
# (Simplified - actual implementation would need TDEE estimation)
|
|
||||||
|
|
||||||
# For now, return placeholder
|
|
||||||
return {
|
|
||||||
'best_lag': 7,
|
|
||||||
'correlation': -0.45, # Placeholder
|
|
||||||
'direction': 'negative', # Higher deficit = lower weight (expected)
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': len(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""Correlate protein intake with LBM trend"""
|
|
||||||
# TODO: Implement full correlation calculation
|
|
||||||
return {
|
|
||||||
'best_lag': 0,
|
|
||||||
'correlation': 0.32, # Placeholder
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 28
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate training load with HRV or RHR
|
|
||||||
Test lags: 1, 2, 3 days
|
|
||||||
"""
|
|
||||||
# TODO: Implement full correlation calculation
|
|
||||||
if vital == 'hrv':
|
|
||||||
return {
|
|
||||||
'best_lag': 1,
|
|
||||||
'correlation': -0.38, # Negative = high load reduces HRV (expected)
|
|
||||||
'direction': 'negative',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 25
|
|
||||||
}
|
|
||||||
else: # rhr
|
|
||||||
return {
|
|
||||||
'best_lag': 1,
|
|
||||||
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'data_points': 25
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C4: Sleep vs. Recovery Correlation
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_correlation_sleep_recovery(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Correlate sleep quality/duration with recovery score
|
|
||||||
"""
|
|
||||||
# TODO: Implement full correlation
|
|
||||||
return {
|
|
||||||
'correlation': 0.65, # Strong positive (expected)
|
|
||||||
'direction': 'positive',
|
|
||||||
'confidence': 'high',
|
|
||||||
'data_points': 28
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C6: Plateau Detector
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_plateau_detected(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Detect if user is in a plateau based on goal mode
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
'plateau_detected': True/False,
|
|
||||||
'plateau_type': 'weight_loss'/'strength'/'endurance'/None,
|
|
||||||
'confidence': 'high'/'medium'/'low',
|
|
||||||
'duration_days': X,
|
|
||||||
'top_factors': [list of potential causes]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from data_layer.scores import get_user_focus_weights
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Determine primary focus area
|
|
||||||
top_focus = max(focus_weights, key=focus_weights.get)
|
|
||||||
|
|
||||||
# Check for plateau based on focus area
|
|
||||||
if top_focus in ['körpergewicht', 'körperfett']:
|
|
||||||
return _detect_weight_plateau(profile_id)
|
|
||||||
elif top_focus == 'kraftaufbau':
|
|
||||||
return _detect_strength_plateau(profile_id)
|
|
||||||
elif top_focus == 'cardio':
|
|
||||||
return _detect_endurance_plateau(profile_id)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_weight_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect weight loss plateau"""
|
|
||||||
from data_layer.body_metrics import calculate_weight_28d_slope
|
|
||||||
from data_layer.nutrition_metrics import calculate_nutrition_score
|
|
||||||
|
|
||||||
slope = calculate_weight_28d_slope(profile_id)
|
|
||||||
nutrition_score = calculate_nutrition_score(profile_id)
|
|
||||||
|
|
||||||
if slope is None:
|
|
||||||
return {'plateau_detected': False, 'reason': 'Insufficient data'}
|
|
||||||
|
|
||||||
# Plateau = flat weight for 28 days despite adherence
|
|
||||||
is_plateau = abs(slope) < 0.02 and nutrition_score and nutrition_score > 70
|
|
||||||
|
|
||||||
if is_plateau:
|
|
||||||
factors = []
|
|
||||||
|
|
||||||
# Check potential factors
|
|
||||||
if nutrition_score > 85:
|
|
||||||
factors.append('Hohe Adhärenz trotz Stagnation → mögliche Anpassung des Stoffwechsels')
|
|
||||||
|
|
||||||
# Check if deficit is too small
|
|
||||||
from data_layer.nutrition_metrics import calculate_energy_balance_7d
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
if balance and balance > -200:
|
|
||||||
factors.append('Energiedefizit zu gering (<200 kcal/Tag)')
|
|
||||||
|
|
||||||
# Check water retention (if waist is shrinking but weight stable)
|
|
||||||
from data_layer.body_metrics import calculate_waist_28d_delta
|
|
||||||
waist_delta = calculate_waist_28d_delta(profile_id)
|
|
||||||
if waist_delta and waist_delta < -1:
|
|
||||||
factors.append('Taillenumfang sinkt → mögliche Wasserretention maskiert Fettabbau')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plateau_detected': True,
|
|
||||||
'plateau_type': 'weight_loss',
|
|
||||||
'confidence': 'high' if len(factors) >= 2 else 'medium',
|
|
||||||
'duration_days': 28,
|
|
||||||
'top_factors': factors[:3]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {'plateau_detected': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_strength_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect strength training plateau"""
|
|
||||||
from data_layer.body_metrics import calculate_lbm_28d_change
|
|
||||||
from data_layer.activity_metrics import calculate_activity_score
|
|
||||||
from data_layer.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
activity_score = calculate_activity_score(profile_id)
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
|
|
||||||
if lbm_change is None:
|
|
||||||
return {'plateau_detected': False, 'reason': 'Insufficient data'}
|
|
||||||
|
|
||||||
# Plateau = flat LBM despite high activity score
|
|
||||||
is_plateau = abs(lbm_change) < 0.3 and activity_score and activity_score > 75
|
|
||||||
|
|
||||||
if is_plateau:
|
|
||||||
factors = []
|
|
||||||
|
|
||||||
if recovery_score and recovery_score < 60:
|
|
||||||
factors.append('Recovery Score niedrig → möglicherweise Übertraining')
|
|
||||||
|
|
||||||
from data_layer.nutrition_metrics import calculate_protein_adequacy_28d
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score and protein_score < 70:
|
|
||||||
factors.append('Proteinzufuhr unter Zielbereich')
|
|
||||||
|
|
||||||
from data_layer.activity_metrics import calculate_monotony_score
|
|
||||||
monotony = calculate_monotony_score(profile_id)
|
|
||||||
if monotony and monotony > 2.0:
|
|
||||||
factors.append('Hohe Trainingsmonotonie → Stimulus-Anpassung')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'plateau_detected': True,
|
|
||||||
'plateau_type': 'strength',
|
|
||||||
'confidence': 'medium',
|
|
||||||
'duration_days': 28,
|
|
||||||
'top_factors': factors[:3]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {'plateau_detected': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_endurance_plateau(profile_id: str) -> Dict:
|
|
||||||
"""Detect endurance plateau"""
|
|
||||||
from data_layer.activity_metrics import calculate_training_minutes_week, calculate_monotony_score
|
|
||||||
from data_layer.recovery_metrics import calculate_vo2max_trend_28d
|
|
||||||
|
|
||||||
# TODO: Implement when vitals_baseline.vo2_max is populated
|
|
||||||
return {'plateau_detected': False, 'reason': 'VO2max tracking not yet implemented'}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# C7: Multi-Factor Driver Panel
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_top_drivers(profile_id: str) -> Optional[List[Dict]]:
|
|
||||||
"""
|
|
||||||
Calculate top influencing factors for goal progress
|
|
||||||
|
|
||||||
Returns list of drivers:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'factor': 'Energiebilanz',
|
|
||||||
'status': 'förderlich'/'neutral'/'hinderlich',
|
|
||||||
'evidence': 'hoch'/'mittel'/'niedrig',
|
|
||||||
'reason': '1-sentence explanation'
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
drivers = []
|
|
||||||
|
|
||||||
# 1. Energy balance
|
|
||||||
from data_layer.nutrition_metrics import calculate_energy_balance_7d
|
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
|
||||||
if balance is not None:
|
|
||||||
if -500 <= balance <= -200:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Moderates Defizit ({int(balance)} kcal/Tag) unterstützt Fettabbau'
|
|
||||||
elif balance < -800:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Sehr großes Defizit ({int(balance)} kcal/Tag) → Risiko für Magermasseverlust'
|
|
||||||
elif -200 < balance < 200:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = 'Energiebilanz ausgeglichen'
|
|
||||||
else:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Energieüberschuss ({int(balance)} kcal/Tag)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Energiebilanz',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. Protein adequacy
|
|
||||||
from data_layer.nutrition_metrics import calculate_protein_adequacy_28d
|
|
||||||
protein_score = calculate_protein_adequacy_28d(profile_id)
|
|
||||||
if protein_score is not None:
|
|
||||||
if protein_score >= 80:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Proteinzufuhr konstant im Zielbereich (Score: {protein_score})'
|
|
||||||
elif protein_score >= 60:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Proteinzufuhr teilweise im Zielbereich (Score: {protein_score})'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Proteinzufuhr häufig unter Zielbereich (Score: {protein_score})'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Proteinzufuhr',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 3. Sleep duration
|
|
||||||
from data_layer.recovery_metrics import calculate_sleep_avg_duration_7d
|
|
||||||
sleep_hours = calculate_sleep_avg_duration_7d(profile_id)
|
|
||||||
if sleep_hours is not None:
|
|
||||||
if sleep_hours >= 7:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Schlafdauer ausreichend ({sleep_hours:.1f}h/Nacht)'
|
|
||||||
elif sleep_hours >= 6.5:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Schlafdauer knapp ausreichend ({sleep_hours:.1f}h/Nacht)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Schlafdauer zu gering ({sleep_hours:.1f}h/Nacht < 7h Empfehlung)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Schlafdauer',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 4. Sleep regularity
|
|
||||||
from data_layer.recovery_metrics import calculate_sleep_regularity_proxy
|
|
||||||
regularity = calculate_sleep_regularity_proxy(profile_id)
|
|
||||||
if regularity is not None:
|
|
||||||
if regularity <= 45:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Schlafrhythmus regelmäßig (Abweichung: {int(regularity)} min)'
|
|
||||||
elif regularity <= 75:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Schlafrhythmus moderat variabel (Abweichung: {int(regularity)} min)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Schlafrhythmus stark variabel (Abweichung: {int(regularity)} min)'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Schlafregelmäßigkeit',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 5. Training consistency
|
|
||||||
from data_layer.activity_metrics import calculate_training_frequency_7d
|
|
||||||
frequency = calculate_training_frequency_7d(profile_id)
|
|
||||||
if frequency is not None:
|
|
||||||
if 3 <= frequency <= 6:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Trainingsfrequenz im Zielbereich ({frequency}× pro Woche)'
|
|
||||||
elif frequency <= 2:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Trainingsfrequenz zu niedrig ({frequency}× pro Woche)'
|
|
||||||
else:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Trainingsfrequenz sehr hoch ({frequency}× pro Woche) → Recovery beachten'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Trainingskonsistenz',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 6. Quality sessions
|
|
||||||
from data_layer.activity_metrics import calculate_quality_sessions_pct
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id)
|
|
||||||
if quality_pct is not None:
|
|
||||||
if quality_pct >= 75:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'{quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
elif quality_pct >= 50:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'{quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Nur {quality_pct}% der Trainings mit guter Qualität'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Trainingsqualität',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 7. Recovery score
|
|
||||||
from data_layer.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
recovery = calculate_recovery_score_v2(profile_id)
|
|
||||||
if recovery is not None:
|
|
||||||
if recovery >= 70:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Recovery Score gut ({recovery}/100)'
|
|
||||||
elif recovery >= 50:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Recovery Score moderat ({recovery}/100)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Recovery Score niedrig ({recovery}/100) → mehr Erholung nötig'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Recovery',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'hoch',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# 8. Rest day compliance
|
|
||||||
from data_layer.activity_metrics import calculate_rest_day_compliance
|
|
||||||
compliance = calculate_rest_day_compliance(profile_id)
|
|
||||||
if compliance is not None:
|
|
||||||
if compliance >= 80:
|
|
||||||
status = 'förderlich'
|
|
||||||
reason = f'Ruhetage gut eingehalten ({compliance}%)'
|
|
||||||
elif compliance >= 60:
|
|
||||||
status = 'neutral'
|
|
||||||
reason = f'Ruhetage teilweise eingehalten ({compliance}%)'
|
|
||||||
else:
|
|
||||||
status = 'hinderlich'
|
|
||||||
reason = f'Ruhetage häufig ignoriert ({compliance}%) → Übertrainingsrisiko'
|
|
||||||
|
|
||||||
drivers.append({
|
|
||||||
'factor': 'Ruhetagsrespekt',
|
|
||||||
'status': status,
|
|
||||||
'evidence': 'mittel',
|
|
||||||
'reason': reason
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by importance: hinderlich first, then förderlich, then neutral
|
|
||||||
priority = {'hinderlich': 0, 'förderlich': 1, 'neutral': 2}
|
|
||||||
drivers.sort(key=lambda d: priority[d['status']])
|
|
||||||
|
|
||||||
return drivers[:8] # Top 8 drivers
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Confidence/Evidence Levels
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_correlation_confidence(data_points: int, correlation: float) -> str:
|
|
||||||
"""
|
|
||||||
Determine confidence level for correlation
|
|
||||||
|
|
||||||
Returns: 'high', 'medium', or 'low'
|
|
||||||
"""
|
|
||||||
# Need sufficient data points
|
|
||||||
if data_points < 20:
|
|
||||||
return 'low'
|
|
||||||
|
|
||||||
# Strong correlation with good data
|
|
||||||
if data_points >= 40 and abs(correlation) >= 0.5:
|
|
||||||
return 'high'
|
|
||||||
elif data_points >= 30 and abs(correlation) >= 0.4:
|
|
||||||
return 'medium'
|
|
||||||
else:
|
|
||||||
return 'low'
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
"""
|
|
||||||
Health Metrics Data Layer
|
|
||||||
|
|
||||||
Provides structured data for vital signs and health monitoring.
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- get_resting_heart_rate_data(): Average RHR with trend
|
|
||||||
- get_heart_rate_variability_data(): Average HRV with trend
|
|
||||||
- get_vo2_max_data(): Latest VO2 Max value
|
|
||||||
|
|
||||||
All functions return structured data (dict) without formatting.
|
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
|
||||||
|
|
||||||
|
|
||||||
def get_resting_heart_rate_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 7
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get average resting heart rate with trend.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 7)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"avg_rhr": int, # beats per minute
|
|
||||||
"min_rhr": int,
|
|
||||||
"max_rhr": int,
|
|
||||||
"measurements": int,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_vitals_avg_hr(pid, days) formatted string
|
|
||||||
NEW: Structured data with min/max
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
AVG(resting_hr) as avg,
|
|
||||||
MIN(resting_hr) as min,
|
|
||||||
MAX(resting_hr) as max,
|
|
||||||
COUNT(*) as count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s
|
|
||||||
AND date >= %s
|
|
||||||
AND resting_hr IS NOT NULL""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row or row['count'] == 0:
|
|
||||||
return {
|
|
||||||
"avg_rhr": 0,
|
|
||||||
"min_rhr": 0,
|
|
||||||
"max_rhr": 0,
|
|
||||||
"measurements": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
measurements = row['count']
|
|
||||||
confidence = calculate_confidence(measurements, days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"avg_rhr": safe_int(row['avg']),
|
|
||||||
"min_rhr": safe_int(row['min']),
|
|
||||||
"max_rhr": safe_int(row['max']),
|
|
||||||
"measurements": measurements,
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_heart_rate_variability_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 7
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get average heart rate variability with trend.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 7)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"avg_hrv": int, # milliseconds
|
|
||||||
"min_hrv": int,
|
|
||||||
"max_hrv": int,
|
|
||||||
"measurements": int,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_vitals_avg_hrv(pid, days) formatted string
|
|
||||||
NEW: Structured data with min/max
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
AVG(hrv) as avg,
|
|
||||||
MIN(hrv) as min,
|
|
||||||
MAX(hrv) as max,
|
|
||||||
COUNT(*) as count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s
|
|
||||||
AND date >= %s
|
|
||||||
AND hrv IS NOT NULL""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row or row['count'] == 0:
|
|
||||||
return {
|
|
||||||
"avg_hrv": 0,
|
|
||||||
"min_hrv": 0,
|
|
||||||
"max_hrv": 0,
|
|
||||||
"measurements": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
measurements = row['count']
|
|
||||||
confidence = calculate_confidence(measurements, days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"avg_hrv": safe_int(row['avg']),
|
|
||||||
"min_hrv": safe_int(row['min']),
|
|
||||||
"max_hrv": safe_int(row['max']),
|
|
||||||
"measurements": measurements,
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_vo2_max_data(
|
|
||||||
profile_id: str
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get latest VO2 Max value with date.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"vo2_max": float, # ml/kg/min
|
|
||||||
"date": date,
|
|
||||||
"confidence": str
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_vitals_vo2_max(pid) formatted string
|
|
||||||
NEW: Structured data with date
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT vo2_max, date FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s AND vo2_max IS NOT NULL
|
|
||||||
ORDER BY date DESC LIMIT 1""",
|
|
||||||
(profile_id,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return {
|
|
||||||
"vo2_max": 0.0,
|
|
||||||
"date": None,
|
|
||||||
"confidence": "insufficient"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"vo2_max": safe_float(row['vo2_max']),
|
|
||||||
"date": row['date'],
|
|
||||||
"confidence": "high"
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,878 +0,0 @@
|
||||||
"""
|
|
||||||
Recovery Metrics Data Layer
|
|
||||||
|
|
||||||
Provides structured data for recovery tracking and analysis.
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- get_sleep_duration_data(): Average sleep duration
|
|
||||||
- get_sleep_quality_data(): Sleep quality score (Deep+REM %)
|
|
||||||
- get_rest_days_data(): Rest day count and types
|
|
||||||
|
|
||||||
All functions return structured data (dict) without formatting.
|
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
|
||||||
|
|
||||||
|
|
||||||
def get_sleep_duration_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 7
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Calculate average sleep duration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 7)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"avg_duration_hours": float,
|
|
||||||
"avg_duration_minutes": int,
|
|
||||||
"total_nights": int,
|
|
||||||
"nights_with_data": int,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_sleep_avg_duration(pid, days) formatted string
|
|
||||||
NEW: Structured data with hours and minutes
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT sleep_segments FROM sleep_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date DESC""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"avg_duration_hours": 0.0,
|
|
||||||
"avg_duration_minutes": 0,
|
|
||||||
"total_nights": 0,
|
|
||||||
"nights_with_data": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
total_minutes = 0
|
|
||||||
nights_with_data = 0
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
segments = row['sleep_segments']
|
|
||||||
if segments:
|
|
||||||
night_minutes = sum(seg.get('duration_min', 0) for seg in segments)
|
|
||||||
if night_minutes > 0:
|
|
||||||
total_minutes += night_minutes
|
|
||||||
nights_with_data += 1
|
|
||||||
|
|
||||||
if nights_with_data == 0:
|
|
||||||
return {
|
|
||||||
"avg_duration_hours": 0.0,
|
|
||||||
"avg_duration_minutes": 0,
|
|
||||||
"total_nights": len(rows),
|
|
||||||
"nights_with_data": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
avg_minutes = int(total_minutes / nights_with_data)
|
|
||||||
avg_hours = avg_minutes / 60
|
|
||||||
|
|
||||||
confidence = calculate_confidence(nights_with_data, days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"avg_duration_hours": round(avg_hours, 1),
|
|
||||||
"avg_duration_minutes": avg_minutes,
|
|
||||||
"total_nights": len(rows),
|
|
||||||
"nights_with_data": nights_with_data,
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_sleep_quality_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 7
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Calculate sleep quality score (Deep+REM percentage).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 7)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"quality_score": float, # 0-100, Deep+REM percentage
|
|
||||||
"avg_deep_rem_minutes": int,
|
|
||||||
"avg_total_minutes": int,
|
|
||||||
"avg_light_minutes": int,
|
|
||||||
"avg_awake_minutes": int,
|
|
||||||
"nights_analyzed": int,
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_sleep_avg_quality(pid, days) formatted string
|
|
||||||
NEW: Complete sleep phase breakdown
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT sleep_segments FROM sleep_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date DESC""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"quality_score": 0.0,
|
|
||||||
"avg_deep_rem_minutes": 0,
|
|
||||||
"avg_total_minutes": 0,
|
|
||||||
"avg_light_minutes": 0,
|
|
||||||
"avg_awake_minutes": 0,
|
|
||||||
"nights_analyzed": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
total_quality = 0
|
|
||||||
total_deep_rem = 0
|
|
||||||
total_light = 0
|
|
||||||
total_awake = 0
|
|
||||||
total_all = 0
|
|
||||||
count = 0
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
segments = row['sleep_segments']
|
|
||||||
if segments:
|
|
||||||
# Note: segments use 'phase' key, stored lowercase (deep, rem, light, awake)
|
|
||||||
deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') in ['deep', 'rem'])
|
|
||||||
light_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'light')
|
|
||||||
awake_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'awake')
|
|
||||||
total_min = sum(s.get('duration_min', 0) for s in segments)
|
|
||||||
|
|
||||||
if total_min > 0:
|
|
||||||
quality_pct = (deep_rem_min / total_min) * 100
|
|
||||||
total_quality += quality_pct
|
|
||||||
total_deep_rem += deep_rem_min
|
|
||||||
total_light += light_min
|
|
||||||
total_awake += awake_min
|
|
||||||
total_all += total_min
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
return {
|
|
||||||
"quality_score": 0.0,
|
|
||||||
"avg_deep_rem_minutes": 0,
|
|
||||||
"avg_total_minutes": 0,
|
|
||||||
"avg_light_minutes": 0,
|
|
||||||
"avg_awake_minutes": 0,
|
|
||||||
"nights_analyzed": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
avg_quality = total_quality / count
|
|
||||||
avg_deep_rem = int(total_deep_rem / count)
|
|
||||||
avg_total = int(total_all / count)
|
|
||||||
avg_light = int(total_light / count)
|
|
||||||
avg_awake = int(total_awake / count)
|
|
||||||
|
|
||||||
confidence = calculate_confidence(count, days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"quality_score": round(avg_quality, 1),
|
|
||||||
"avg_deep_rem_minutes": avg_deep_rem,
|
|
||||||
"avg_total_minutes": avg_total,
|
|
||||||
"avg_light_minutes": avg_light,
|
|
||||||
"avg_awake_minutes": avg_awake,
|
|
||||||
"nights_analyzed": count,
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_rest_days_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 30
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Get rest days count and breakdown by type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User profile ID
|
|
||||||
days: Analysis window (default 30)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"total_rest_days": int,
|
|
||||||
"rest_types": {
|
|
||||||
"muscle_recovery": int,
|
|
||||||
"cardio_recovery": int,
|
|
||||||
"mental_rest": int,
|
|
||||||
"deload": int,
|
|
||||||
"injury": int
|
|
||||||
},
|
|
||||||
"rest_frequency": float, # days per week
|
|
||||||
"confidence": str,
|
|
||||||
"days_analyzed": int
|
|
||||||
}
|
|
||||||
|
|
||||||
Migration from Phase 0b:
|
|
||||||
OLD: get_rest_days_count(pid, days) formatted string
|
|
||||||
NEW: Complete breakdown by rest type
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Get total distinct rest days
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT COUNT(DISTINCT date) as count FROM rest_days
|
|
||||||
WHERE profile_id=%s AND date >= %s""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
total_row = cur.fetchone()
|
|
||||||
total_count = total_row['count'] if total_row else 0
|
|
||||||
|
|
||||||
# Get breakdown by focus type
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT focus, COUNT(*) as count FROM rest_days
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY focus""",
|
|
||||||
(profile_id, cutoff)
|
|
||||||
)
|
|
||||||
type_rows = cur.fetchall()
|
|
||||||
|
|
||||||
rest_types = {
|
|
||||||
"muscle_recovery": 0,
|
|
||||||
"cardio_recovery": 0,
|
|
||||||
"mental_rest": 0,
|
|
||||||
"deload": 0,
|
|
||||||
"injury": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in type_rows:
|
|
||||||
focus = row['focus']
|
|
||||||
if focus in rest_types:
|
|
||||||
rest_types[focus] = row['count']
|
|
||||||
|
|
||||||
# Calculate frequency (rest days per week)
|
|
||||||
rest_frequency = (total_count / days * 7) if days > 0 else 0.0
|
|
||||||
|
|
||||||
confidence = calculate_confidence(total_count, days, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_rest_days": total_count,
|
|
||||||
"rest_types": rest_types,
|
|
||||||
"rest_frequency": round(rest_frequency, 1),
|
|
||||||
"confidence": confidence,
|
|
||||||
"days_analyzed": days
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Calculated Metrics (migrated from calculations/recovery_metrics.py)
|
|
||||||
# ============================================================================
|
|
||||||
# These functions return simple values for placeholders and scoring.
|
|
||||||
# Use get_*_data() functions above for structured chart data.
|
|
||||||
|
|
||||||
def calculate_recovery_score_v2(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Improved recovery/readiness score (0-100)
|
|
||||||
|
|
||||||
Components:
|
|
||||||
- HRV status (25%)
|
|
||||||
- RHR status (20%)
|
|
||||||
- Sleep duration (20%)
|
|
||||||
- Sleep debt (10%)
|
|
||||||
- Sleep regularity (10%)
|
|
||||||
- Recent load balance (10%)
|
|
||||||
- Data quality (5%)
|
|
||||||
"""
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. HRV status (25%)
|
|
||||||
hrv_score = _score_hrv_vs_baseline(profile_id)
|
|
||||||
if hrv_score is not None:
|
|
||||||
components.append(('hrv', hrv_score, 25))
|
|
||||||
|
|
||||||
# 2. RHR status (20%)
|
|
||||||
rhr_score = _score_rhr_vs_baseline(profile_id)
|
|
||||||
if rhr_score is not None:
|
|
||||||
components.append(('rhr', rhr_score, 20))
|
|
||||||
|
|
||||||
# 3. Sleep duration (20%)
|
|
||||||
sleep_duration_score = _score_sleep_duration(profile_id)
|
|
||||||
if sleep_duration_score is not None:
|
|
||||||
components.append(('sleep_duration', sleep_duration_score, 20))
|
|
||||||
|
|
||||||
# 4. Sleep debt (10%)
|
|
||||||
sleep_debt_score = _score_sleep_debt(profile_id)
|
|
||||||
if sleep_debt_score is not None:
|
|
||||||
components.append(('sleep_debt', sleep_debt_score, 10))
|
|
||||||
|
|
||||||
# 5. Sleep regularity (10%)
|
|
||||||
regularity_score = _score_sleep_regularity(profile_id)
|
|
||||||
if regularity_score is not None:
|
|
||||||
components.append(('regularity', regularity_score, 10))
|
|
||||||
|
|
||||||
# 6. Recent load balance (10%)
|
|
||||||
load_score = _score_recent_load_balance(profile_id)
|
|
||||||
if load_score is not None:
|
|
||||||
components.append(('load', load_score, 10))
|
|
||||||
|
|
||||||
# 7. Data quality (5%)
|
|
||||||
quality_score = _score_recovery_data_quality(profile_id)
|
|
||||||
if quality_score is not None:
|
|
||||||
components.append(('data_quality', quality_score, 5))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
final_score = int(total_score / total_weight)
|
|
||||||
|
|
||||||
return final_score
|
|
||||||
|
|
||||||
|
|
||||||
def _score_hrv_vs_baseline(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score HRV relative to 28d baseline (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent HRV (last 3 days average)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as recent_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent_hrv = recent_row['recent_hrv']
|
|
||||||
|
|
||||||
# Get baseline (28d average, excluding last 3 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as baseline_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline_hrv = baseline_row['baseline_hrv']
|
|
||||||
|
|
||||||
# Calculate percentage deviation
|
|
||||||
deviation_pct = ((recent_hrv - baseline_hrv) / baseline_hrv) * 100
|
|
||||||
|
|
||||||
# Score: higher HRV = better recovery
|
|
||||||
if deviation_pct >= 10:
|
|
||||||
return 100
|
|
||||||
elif deviation_pct >= 5:
|
|
||||||
return 90
|
|
||||||
elif deviation_pct >= 0:
|
|
||||||
return 75
|
|
||||||
elif deviation_pct >= -5:
|
|
||||||
return 60
|
|
||||||
elif deviation_pct >= -10:
|
|
||||||
return 45
|
|
||||||
else:
|
|
||||||
return max(20, 45 + int(deviation_pct * 2))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_rhr_vs_baseline(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score RHR relative to 28d baseline (0-100)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get recent RHR (last 3 days average)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as recent_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent_rhr = recent_row['recent_rhr']
|
|
||||||
|
|
||||||
# Get baseline (28d average, excluding last 3 days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as baseline_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline_rhr = baseline_row['baseline_rhr']
|
|
||||||
|
|
||||||
# Calculate difference (bpm)
|
|
||||||
difference = recent_rhr - baseline_rhr
|
|
||||||
|
|
||||||
# Score: lower RHR = better recovery
|
|
||||||
if difference <= -3:
|
|
||||||
return 100
|
|
||||||
elif difference <= -1:
|
|
||||||
return 90
|
|
||||||
elif difference <= 1:
|
|
||||||
return 75
|
|
||||||
elif difference <= 3:
|
|
||||||
return 60
|
|
||||||
elif difference <= 5:
|
|
||||||
return 45
|
|
||||||
else:
|
|
||||||
return max(20, 45 - (difference * 5))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_duration(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score recent sleep duration (0-100)"""
|
|
||||||
avg_sleep_hours = calculate_sleep_avg_duration_7d(profile_id)
|
|
||||||
|
|
||||||
if avg_sleep_hours is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Target: 7-9 hours
|
|
||||||
if 7 <= avg_sleep_hours <= 9:
|
|
||||||
return 100
|
|
||||||
elif 6.5 <= avg_sleep_hours < 7:
|
|
||||||
return 85
|
|
||||||
elif 6 <= avg_sleep_hours < 6.5:
|
|
||||||
return 70
|
|
||||||
elif avg_sleep_hours >= 9.5:
|
|
||||||
return 85 # Too much sleep can indicate fatigue
|
|
||||||
else:
|
|
||||||
return max(40, int(avg_sleep_hours * 10))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_debt(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score sleep debt (0-100)"""
|
|
||||||
debt_hours = calculate_sleep_debt_hours(profile_id)
|
|
||||||
|
|
||||||
if debt_hours is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Score based on accumulated debt
|
|
||||||
if debt_hours <= 1:
|
|
||||||
return 100
|
|
||||||
elif debt_hours <= 3:
|
|
||||||
return 85
|
|
||||||
elif debt_hours <= 5:
|
|
||||||
return 70
|
|
||||||
elif debt_hours <= 8:
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
return max(30, 100 - (debt_hours * 8))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_regularity(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score sleep regularity (0-100)"""
|
|
||||||
regularity_proxy = calculate_sleep_regularity_proxy(profile_id)
|
|
||||||
|
|
||||||
if regularity_proxy is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# regularity_proxy = mean absolute shift in minutes
|
|
||||||
# Lower = better
|
|
||||||
if regularity_proxy <= 30:
|
|
||||||
return 100
|
|
||||||
elif regularity_proxy <= 45:
|
|
||||||
return 85
|
|
||||||
elif regularity_proxy <= 60:
|
|
||||||
return 70
|
|
||||||
elif regularity_proxy <= 90:
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
return max(30, 100 - int(regularity_proxy / 2))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_recent_load_balance(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score recent training load balance (0-100)"""
|
|
||||||
load_3d = calculate_recent_load_balance_3d(profile_id)
|
|
||||||
|
|
||||||
if load_3d is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Proxy load: 0-300 = low, 300-600 = moderate, >600 = high
|
|
||||||
if load_3d < 300:
|
|
||||||
# Under-loading
|
|
||||||
return 90
|
|
||||||
elif load_3d <= 600:
|
|
||||||
# Optimal
|
|
||||||
return 100
|
|
||||||
elif load_3d <= 900:
|
|
||||||
# High but manageable
|
|
||||||
return 75
|
|
||||||
elif load_3d <= 1200:
|
|
||||||
# Very high
|
|
||||||
return 55
|
|
||||||
else:
|
|
||||||
# Excessive
|
|
||||||
return max(30, 100 - (load_3d / 20))
|
|
||||||
|
|
||||||
|
|
||||||
def _score_recovery_data_quality(profile_id: str) -> Optional[int]:
|
|
||||||
"""Score data quality for recovery metrics (0-100)"""
|
|
||||||
quality = calculate_recovery_data_quality(profile_id)
|
|
||||||
return quality['overall_score']
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Individual Recovery Metrics
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_hrv_vs_baseline_pct(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate HRV deviation from baseline (percentage)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Recent HRV (3d avg)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as recent_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = recent_row['recent_hrv']
|
|
||||||
|
|
||||||
# Baseline (28d avg, excluding last 3d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(hrv) as baseline_hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_hrv']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline = baseline_row['baseline_hrv']
|
|
||||||
|
|
||||||
deviation_pct = ((recent - baseline) / baseline) * 100
|
|
||||||
return round(deviation_pct, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_rhr_vs_baseline_pct(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate RHR deviation from baseline (percentage)"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Recent RHR (3d avg)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as recent_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
recent_row = cur.fetchone()
|
|
||||||
if not recent_row or not recent_row['recent_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
recent = recent_row['recent_rhr']
|
|
||||||
|
|
||||||
# Baseline
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(resting_hr) as baseline_rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
AND date < CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
baseline_row = cur.fetchone()
|
|
||||||
if not baseline_row or not baseline_row['baseline_rhr']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
baseline = baseline_row['baseline_rhr']
|
|
||||||
|
|
||||||
deviation_pct = ((recent - baseline) / baseline) * 100
|
|
||||||
return round(deviation_pct, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Calculate average sleep duration (hours) last 7 days"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT AVG(duration_minutes) as avg_sleep_min
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or not row['avg_sleep_min']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_hours = row['avg_sleep_min'] / 60
|
|
||||||
return round(avg_hours, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Calculate accumulated sleep debt (hours) last 14 days
|
|
||||||
Assumes 7.5h target per night
|
|
||||||
"""
|
|
||||||
target_hours = 7.5
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = [row['duration_minutes'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(sleep_data) < 10: # Need at least 10 days
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate cumulative debt
|
|
||||||
total_debt_min = sum(max(0, (target_hours * 60) - sleep_min) for sleep_min in sleep_data)
|
|
||||||
debt_hours = total_debt_min / 60
|
|
||||||
|
|
||||||
return round(debt_hours, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Sleep regularity proxy: mean absolute shift from previous day (minutes)
|
|
||||||
Lower = more regular
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT bedtime, wake_time, date
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
AND bedtime IS NOT NULL
|
|
||||||
AND wake_time IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(sleep_data) < 7:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate day-to-day shifts
|
|
||||||
shifts = []
|
|
||||||
for i in range(1, len(sleep_data)):
|
|
||||||
prev = sleep_data[i-1]
|
|
||||||
curr = sleep_data[i]
|
|
||||||
|
|
||||||
# Bedtime shift (minutes)
|
|
||||||
prev_bedtime = prev['bedtime']
|
|
||||||
curr_bedtime = curr['bedtime']
|
|
||||||
|
|
||||||
# Convert to minutes since midnight
|
|
||||||
prev_bed_min = prev_bedtime.hour * 60 + prev_bedtime.minute
|
|
||||||
curr_bed_min = curr_bedtime.hour * 60 + curr_bedtime.minute
|
|
||||||
|
|
||||||
# Handle cross-midnight (e.g., 23:00 to 01:00)
|
|
||||||
bed_shift = abs(curr_bed_min - prev_bed_min)
|
|
||||||
if bed_shift > 720: # More than 12 hours = wrapped around
|
|
||||||
bed_shift = 1440 - bed_shift
|
|
||||||
|
|
||||||
shifts.append(bed_shift)
|
|
||||||
|
|
||||||
mean_shift = sum(shifts) / len(shifts)
|
|
||||||
return round(mean_shift, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_recent_load_balance_3d(profile_id: str) -> Optional[int]:
|
|
||||||
"""Calculate proxy internal load last 3 days"""
|
|
||||||
from data_layer.activity_metrics import calculate_proxy_internal_load_7d
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT SUM(duration_min) as total_duration
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '3 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Simplified 3d load (duration-based)
|
|
||||||
return int(row['total_duration'] or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate sleep quality score (0-100) based on deep+REM percentage
|
|
||||||
Last 7 days
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_minutes, deep_minutes, rem_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
|
|
||||||
if len(sleep_data) < 4:
|
|
||||||
return None
|
|
||||||
|
|
||||||
quality_scores = []
|
|
||||||
for s in sleep_data:
|
|
||||||
if s['deep_minutes'] and s['rem_minutes']:
|
|
||||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
|
||||||
# 40-60% deep+REM is good
|
|
||||||
if quality_pct >= 45:
|
|
||||||
quality_scores.append(100)
|
|
||||||
elif quality_pct >= 35:
|
|
||||||
quality_scores.append(75)
|
|
||||||
elif quality_pct >= 25:
|
|
||||||
quality_scores.append(50)
|
|
||||||
else:
|
|
||||||
quality_scores.append(30)
|
|
||||||
|
|
||||||
if not quality_scores:
|
|
||||||
return None
|
|
||||||
|
|
||||||
avg_quality = sum(quality_scores) / len(quality_scores)
|
|
||||||
return int(avg_quality)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Assessment
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_recovery_data_quality(profile_id: str) -> Dict[str, any]:
|
|
||||||
"""
|
|
||||||
Assess data quality for recovery metrics
|
|
||||||
Returns dict with quality score and details
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# HRV measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as hrv_count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND hrv IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
hrv_count = cur.fetchone()['hrv_count']
|
|
||||||
|
|
||||||
# RHR measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as rhr_count
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND resting_hr IS NOT NULL
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
rhr_count = cur.fetchone()['rhr_count']
|
|
||||||
|
|
||||||
# Sleep measurements (28d)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as sleep_count
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
sleep_count = cur.fetchone()['sleep_count']
|
|
||||||
|
|
||||||
# Score components
|
|
||||||
hrv_score = min(100, (hrv_count / 21) * 100) # 21 = 75% coverage
|
|
||||||
rhr_score = min(100, (rhr_count / 21) * 100)
|
|
||||||
sleep_score = min(100, (sleep_count / 21) * 100)
|
|
||||||
|
|
||||||
# Overall score
|
|
||||||
overall_score = int(
|
|
||||||
hrv_score * 0.3 +
|
|
||||||
rhr_score * 0.3 +
|
|
||||||
sleep_score * 0.4
|
|
||||||
)
|
|
||||||
|
|
||||||
if overall_score >= 80:
|
|
||||||
confidence = "high"
|
|
||||||
elif overall_score >= 60:
|
|
||||||
confidence = "medium"
|
|
||||||
else:
|
|
||||||
confidence = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"overall_score": overall_score,
|
|
||||||
"confidence": confidence,
|
|
||||||
"measurements": {
|
|
||||||
"hrv_28d": hrv_count,
|
|
||||||
"rhr_28d": rhr_count,
|
|
||||||
"sleep_28d": sleep_count
|
|
||||||
},
|
|
||||||
"component_scores": {
|
|
||||||
"hrv": int(hrv_score),
|
|
||||||
"rhr": int(rhr_score),
|
|
||||||
"sleep": int(sleep_score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,583 +0,0 @@
|
||||||
"""
|
|
||||||
Scoring Metrics Data Layer
|
|
||||||
|
|
||||||
Provides structured scoring and focus weight functions for all metrics.
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- get_user_focus_weights(): User focus area weights (from DB)
|
|
||||||
- get_focus_area_category(): Category for a focus area
|
|
||||||
- map_focus_to_score_components(): Mapping of focus areas to score components
|
|
||||||
- map_category_de_to_en(): Category translation DE→EN
|
|
||||||
- calculate_category_weight(): Weight for a category
|
|
||||||
- calculate_goal_progress_score(): Goal progress scoring
|
|
||||||
- calculate_health_stability_score(): Health stability scoring
|
|
||||||
- calculate_data_quality_score(): Overall data quality
|
|
||||||
- get_top_priority_goal(): Top goal by weight
|
|
||||||
- get_top_focus_area(): Top focus area by weight
|
|
||||||
- calculate_focus_area_progress(): Progress for specific focus area
|
|
||||||
- calculate_category_progress(): Progress for category
|
|
||||||
|
|
||||||
All functions return structured data (dict) or simple values.
|
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
|
|
||||||
def get_user_focus_weights(profile_id: str) -> Dict[str, float]:
|
|
||||||
"""
|
|
||||||
Get user's focus area weights as dictionary
|
|
||||||
Returns: {'körpergewicht': 30.0, 'kraftaufbau': 25.0, ...}
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT ufw.focus_area_id, ufw.weight as weight_pct, fa.key
|
|
||||||
FROM user_focus_area_weights ufw
|
|
||||||
JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id
|
|
||||||
WHERE ufw.profile_id = %s
|
|
||||||
AND ufw.weight > 0
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
return {
|
|
||||||
row['key']: float(row['weight_pct'])
|
|
||||||
for row in cur.fetchall()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_focus_area_category(focus_area_id: str) -> Optional[str]:
|
|
||||||
"""Get category for a focus area"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT category
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE focus_area_id = %s
|
|
||||||
""", (focus_area_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return row['category'] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def map_focus_to_score_components() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Map focus areas to score components
|
|
||||||
Keys match focus_area_definitions.key (English lowercase)
|
|
||||||
Returns: {'weight_loss': 'body', 'strength': 'activity', ...}
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
# Body Composition → body_progress_score
|
|
||||||
'weight_loss': 'body',
|
|
||||||
'muscle_gain': 'body',
|
|
||||||
'body_recomposition': 'body',
|
|
||||||
|
|
||||||
# Training - Strength → activity_score
|
|
||||||
'strength': 'activity',
|
|
||||||
'strength_endurance': 'activity',
|
|
||||||
'power': 'activity',
|
|
||||||
|
|
||||||
# Training - Mobility → activity_score
|
|
||||||
'flexibility': 'activity',
|
|
||||||
'mobility': 'activity',
|
|
||||||
|
|
||||||
# Endurance → activity_score (could also map to health)
|
|
||||||
'aerobic_endurance': 'activity',
|
|
||||||
'anaerobic_endurance': 'activity',
|
|
||||||
'cardiovascular_health': 'health',
|
|
||||||
|
|
||||||
# Coordination → activity_score
|
|
||||||
'balance': 'activity',
|
|
||||||
'reaction': 'activity',
|
|
||||||
'rhythm': 'activity',
|
|
||||||
'coordination': 'activity',
|
|
||||||
|
|
||||||
# Mental → recovery_score (mental health is part of recovery)
|
|
||||||
'stress_resistance': 'recovery',
|
|
||||||
'concentration': 'recovery',
|
|
||||||
'willpower': 'recovery',
|
|
||||||
'mental_health': 'recovery',
|
|
||||||
|
|
||||||
# Recovery → recovery_score
|
|
||||||
'sleep_quality': 'recovery',
|
|
||||||
'regeneration': 'recovery',
|
|
||||||
'rest': 'recovery',
|
|
||||||
|
|
||||||
# Health → health
|
|
||||||
'metabolic_health': 'health',
|
|
||||||
'blood_pressure': 'health',
|
|
||||||
'hrv': 'health',
|
|
||||||
'general_health': 'health',
|
|
||||||
|
|
||||||
# Nutrition → nutrition_score
|
|
||||||
'protein_intake': 'nutrition',
|
|
||||||
'calorie_balance': 'nutrition',
|
|
||||||
'macro_consistency': 'nutrition',
|
|
||||||
'meal_timing': 'nutrition',
|
|
||||||
'hydration': 'nutrition',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def map_category_de_to_en(category_de: str) -> str:
|
|
||||||
"""
|
|
||||||
Map German category names to English database names
|
|
||||||
"""
|
|
||||||
mapping = {
|
|
||||||
'körper': 'body_composition',
|
|
||||||
'ernährung': 'nutrition', # Note: no nutrition category in DB, returns empty
|
|
||||||
'aktivität': 'training',
|
|
||||||
'recovery': 'recovery',
|
|
||||||
'vitalwerte': 'health',
|
|
||||||
'mental': 'mental',
|
|
||||||
'lebensstil': 'health', # Maps to general health
|
|
||||||
}
|
|
||||||
return mapping.get(category_de, category_de)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_category_weight(profile_id: str, category: str) -> float:
|
|
||||||
"""
|
|
||||||
Calculate total weight for a category
|
|
||||||
Accepts German or English category names
|
|
||||||
Returns sum of all focus area weights in this category
|
|
||||||
"""
|
|
||||||
# Map German to English if needed
|
|
||||||
category_en = map_category_de_to_en(category)
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT key
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE category = %s
|
|
||||||
""", (category_en,))
|
|
||||||
|
|
||||||
focus_areas = [row['key'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
total_weight = sum(
|
|
||||||
focus_weights.get(fa, 0)
|
|
||||||
for fa in focus_areas
|
|
||||||
)
|
|
||||||
|
|
||||||
return total_weight
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Goal Progress Score (Meta-Score with Dynamic Weighting)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_goal_progress_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate overall goal progress score (0-100)
|
|
||||||
Weighted dynamically based on user's focus area priorities
|
|
||||||
|
|
||||||
This is the main meta-score that combines all sub-scores
|
|
||||||
"""
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None # No goals/focus areas configured
|
|
||||||
|
|
||||||
# Calculate sub-scores
|
|
||||||
from data_layer.body_metrics import calculate_body_progress_score
|
|
||||||
from data_layer.nutrition_metrics import calculate_nutrition_score
|
|
||||||
from data_layer.activity_metrics import calculate_activity_score
|
|
||||||
from data_layer.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
|
|
||||||
body_score = calculate_body_progress_score(profile_id, focus_weights)
|
|
||||||
nutrition_score = calculate_nutrition_score(profile_id, focus_weights)
|
|
||||||
activity_score = calculate_activity_score(profile_id, focus_weights)
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
health_risk_score = calculate_health_stability_score(profile_id)
|
|
||||||
|
|
||||||
# Map focus areas to score components
|
|
||||||
focus_to_component = map_focus_to_score_components()
|
|
||||||
|
|
||||||
# Calculate weighted sum
|
|
||||||
total_score = 0.0
|
|
||||||
total_weight = 0.0
|
|
||||||
|
|
||||||
for focus_area_id, weight in focus_weights.items():
|
|
||||||
component = focus_to_component.get(focus_area_id)
|
|
||||||
|
|
||||||
if component == 'body' and body_score is not None:
|
|
||||||
total_score += body_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'nutrition' and nutrition_score is not None:
|
|
||||||
total_score += nutrition_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'activity' and activity_score is not None:
|
|
||||||
total_score += activity_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'recovery' and recovery_score is not None:
|
|
||||||
total_score += recovery_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
elif component == 'health' and health_risk_score is not None:
|
|
||||||
total_score += health_risk_score * weight
|
|
||||||
total_weight += weight
|
|
||||||
|
|
||||||
if total_weight == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Normalize to 0-100
|
|
||||||
final_score = total_score / total_weight
|
|
||||||
|
|
||||||
return int(final_score)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Health stability score (0-100)
|
|
||||||
Components:
|
|
||||||
- Blood pressure status
|
|
||||||
- Sleep quality
|
|
||||||
- Movement baseline
|
|
||||||
- Weight/circumference risk factors
|
|
||||||
- Regularity
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
components = []
|
|
||||||
|
|
||||||
# 1. Blood pressure status (30%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT systolic, diastolic
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND measured_at >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
bp_readings = cur.fetchall()
|
|
||||||
if bp_readings:
|
|
||||||
bp_score = _score_blood_pressure(bp_readings)
|
|
||||||
components.append(('bp', bp_score, 30))
|
|
||||||
|
|
||||||
# 2. Sleep quality (25%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_minutes, deep_minutes, rem_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
sleep_data = cur.fetchall()
|
|
||||||
if sleep_data:
|
|
||||||
sleep_score = _score_sleep_quality(sleep_data)
|
|
||||||
components.append(('sleep', sleep_score, 25))
|
|
||||||
|
|
||||||
# 3. Movement baseline (20%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT duration_min
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
|
||||||
if activities:
|
|
||||||
total_minutes = sum(a['duration_min'] for a in activities)
|
|
||||||
# WHO recommends 150-300 min/week moderate activity
|
|
||||||
movement_score = min(100, (total_minutes / 150) * 100)
|
|
||||||
components.append(('movement', movement_score, 20))
|
|
||||||
|
|
||||||
# 4. Waist circumference risk (15%)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c_waist
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND c_waist IS NOT NULL
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
waist = cur.fetchone()
|
|
||||||
if waist:
|
|
||||||
# Gender-specific thresholds (simplified - should use profile gender)
|
|
||||||
# Men: <94cm good, 94-102 elevated, >102 high risk
|
|
||||||
# Women: <80cm good, 80-88 elevated, >88 high risk
|
|
||||||
# Using conservative thresholds
|
|
||||||
waist_cm = waist['c_waist']
|
|
||||||
if waist_cm < 88:
|
|
||||||
waist_score = 100
|
|
||||||
elif waist_cm < 94:
|
|
||||||
waist_score = 75
|
|
||||||
elif waist_cm < 102:
|
|
||||||
waist_score = 50
|
|
||||||
else:
|
|
||||||
waist_score = 25
|
|
||||||
components.append(('waist', waist_score, 15))
|
|
||||||
|
|
||||||
# 5. Regularity (10%) - sleep timing consistency
|
|
||||||
if len(sleep_data) >= 7:
|
|
||||||
sleep_times = [s['duration_minutes'] for s in sleep_data]
|
|
||||||
avg = sum(sleep_times) / len(sleep_times)
|
|
||||||
variance = sum((x - avg) ** 2 for x in sleep_times) / len(sleep_times)
|
|
||||||
std_dev = variance ** 0.5
|
|
||||||
# Lower std_dev = better consistency
|
|
||||||
regularity_score = max(0, 100 - (std_dev * 2))
|
|
||||||
components.append(('regularity', regularity_score, 10))
|
|
||||||
|
|
||||||
if not components:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
total_score = sum(score * weight for _, score, weight in components)
|
|
||||||
total_weight = sum(weight for _, _, weight in components)
|
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_blood_pressure(readings: List) -> int:
|
|
||||||
"""Score blood pressure readings (0-100)"""
|
|
||||||
# Average last 28 days
|
|
||||||
avg_systolic = sum(r['systolic'] for r in readings) / len(readings)
|
|
||||||
avg_diastolic = sum(r['diastolic'] for r in readings) / len(readings)
|
|
||||||
|
|
||||||
# ESC 2024 Guidelines:
|
|
||||||
# Optimal: <120/80
|
|
||||||
# Normal: 120-129 / 80-84
|
|
||||||
# Elevated: 130-139 / 85-89
|
|
||||||
# Hypertension: ≥140/90
|
|
||||||
|
|
||||||
if avg_systolic < 120 and avg_diastolic < 80:
|
|
||||||
return 100
|
|
||||||
elif avg_systolic < 130 and avg_diastolic < 85:
|
|
||||||
return 85
|
|
||||||
elif avg_systolic < 140 and avg_diastolic < 90:
|
|
||||||
return 65
|
|
||||||
else:
|
|
||||||
return 40
|
|
||||||
|
|
||||||
|
|
||||||
def _score_sleep_quality(sleep_data: List) -> int:
|
|
||||||
"""Score sleep quality (0-100)"""
|
|
||||||
# Average sleep duration and quality
|
|
||||||
avg_total = sum(s['duration_minutes'] for s in sleep_data) / len(sleep_data)
|
|
||||||
avg_total_hours = avg_total / 60
|
|
||||||
|
|
||||||
# Duration score (7+ hours = good)
|
|
||||||
if avg_total_hours >= 8:
|
|
||||||
duration_score = 100
|
|
||||||
elif avg_total_hours >= 7:
|
|
||||||
duration_score = 85
|
|
||||||
elif avg_total_hours >= 6:
|
|
||||||
duration_score = 65
|
|
||||||
else:
|
|
||||||
duration_score = 40
|
|
||||||
|
|
||||||
# Quality score (deep + REM percentage)
|
|
||||||
quality_scores = []
|
|
||||||
for s in sleep_data:
|
|
||||||
if s['deep_minutes'] and s['rem_minutes']:
|
|
||||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
|
||||||
# 40-60% deep+REM is good
|
|
||||||
if quality_pct >= 45:
|
|
||||||
quality_scores.append(100)
|
|
||||||
elif quality_pct >= 35:
|
|
||||||
quality_scores.append(75)
|
|
||||||
elif quality_pct >= 25:
|
|
||||||
quality_scores.append(50)
|
|
||||||
else:
|
|
||||||
quality_scores.append(30)
|
|
||||||
|
|
||||||
if quality_scores:
|
|
||||||
avg_quality = sum(quality_scores) / len(quality_scores)
|
|
||||||
# Weighted: 60% duration, 40% quality
|
|
||||||
return int(duration_score * 0.6 + avg_quality * 0.4)
|
|
||||||
else:
|
|
||||||
return duration_score
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Data Quality Score
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def calculate_data_quality_score(profile_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Overall data quality score (0-100)
|
|
||||||
Combines quality from all modules
|
|
||||||
"""
|
|
||||||
from data_layer.body_metrics import calculate_body_data_quality
|
|
||||||
from data_layer.nutrition_metrics import calculate_nutrition_data_quality
|
|
||||||
from data_layer.activity_metrics import calculate_activity_data_quality
|
|
||||||
from data_layer.recovery_metrics import calculate_recovery_data_quality
|
|
||||||
|
|
||||||
body_quality = calculate_body_data_quality(profile_id)
|
|
||||||
nutrition_quality = calculate_nutrition_data_quality(profile_id)
|
|
||||||
activity_quality = calculate_activity_data_quality(profile_id)
|
|
||||||
recovery_quality = calculate_recovery_data_quality(profile_id)
|
|
||||||
|
|
||||||
# Weighted average (all equal weight)
|
|
||||||
total_score = (
|
|
||||||
body_quality['overall_score'] * 0.25 +
|
|
||||||
nutrition_quality['overall_score'] * 0.25 +
|
|
||||||
activity_quality['overall_score'] * 0.25 +
|
|
||||||
recovery_quality['overall_score'] * 0.25
|
|
||||||
)
|
|
||||||
|
|
||||||
return int(total_score)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Top-Weighted Helpers (instead of "primary goal")
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_top_priority_goal(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Get highest priority goal based on:
|
|
||||||
- Progress gap (distance to target)
|
|
||||||
- Focus area weight
|
|
||||||
Returns goal dict or None
|
|
||||||
"""
|
|
||||||
from goal_utils import get_active_goals
|
|
||||||
|
|
||||||
goals = get_active_goals(profile_id)
|
|
||||||
if not goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
for goal in goals:
|
|
||||||
# Progress gap (0-100, higher = further from target)
|
|
||||||
goal['progress_gap'] = 100 - (goal.get('progress_pct') or 0)
|
|
||||||
|
|
||||||
# Get focus areas for this goal
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT fa.key as focus_area_key
|
|
||||||
FROM goal_focus_contributions gfc
|
|
||||||
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
|
|
||||||
WHERE gfc.goal_id = %s
|
|
||||||
""", (goal['id'],))
|
|
||||||
|
|
||||||
goal_focus_areas = [row['focus_area_key'] for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Sum focus weights
|
|
||||||
goal['total_focus_weight'] = sum(
|
|
||||||
focus_weights.get(fa, 0)
|
|
||||||
for fa in goal_focus_areas
|
|
||||||
)
|
|
||||||
|
|
||||||
# Priority score
|
|
||||||
goal['priority_score'] = goal['progress_gap'] * (goal['total_focus_weight'] / 100)
|
|
||||||
|
|
||||||
# Return goal with highest priority score
|
|
||||||
return max(goals, key=lambda g: g.get('priority_score', 0))
|
|
||||||
|
|
||||||
|
|
||||||
def get_top_focus_area(profile_id: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Get focus area with highest user weight
|
|
||||||
Returns dict with focus_area_id, label, weight, progress
|
|
||||||
"""
|
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
|
||||||
|
|
||||||
if not focus_weights:
|
|
||||||
return None
|
|
||||||
|
|
||||||
top_fa_id = max(focus_weights, key=focus_weights.get)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT key, name_de, category
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE key = %s
|
|
||||||
""", (top_fa_id,))
|
|
||||||
|
|
||||||
fa_def = cur.fetchone()
|
|
||||||
if not fa_def:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Calculate progress for this focus area
|
|
||||||
progress = calculate_focus_area_progress(profile_id, top_fa_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'focus_area_id': top_fa_id,
|
|
||||||
'label': fa_def['name_de'],
|
|
||||||
'category': fa_def['category'],
|
|
||||||
'weight': focus_weights[top_fa_id],
|
|
||||||
'progress': progress
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate progress for a specific focus area (0-100)
|
|
||||||
Average progress of all goals contributing to this focus area
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT g.id, g.progress_pct, gfc.contribution_weight
|
|
||||||
FROM goals g
|
|
||||||
JOIN goal_focus_contributions gfc ON g.id = gfc.goal_id
|
|
||||||
WHERE g.profile_id = %s
|
|
||||||
AND gfc.focus_area_id = (
|
|
||||||
SELECT id FROM focus_area_definitions WHERE key = %s
|
|
||||||
)
|
|
||||||
AND g.status = 'active'
|
|
||||||
""", (profile_id, focus_area_id))
|
|
||||||
|
|
||||||
goals = cur.fetchall()
|
|
||||||
|
|
||||||
if not goals:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weighted average by contribution_weight
|
|
||||||
total_progress = sum(g['progress_pct'] * g['contribution_weight'] for g in goals)
|
|
||||||
total_weight = sum(g['contribution_weight'] for g in goals)
|
|
||||||
|
|
||||||
return int(total_progress / total_weight) if total_weight > 0 else None
|
|
||||||
|
|
||||||
def calculate_category_progress(profile_id: str, category: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Calculate progress score for a focus area category (0-100).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: User's profile ID
|
|
||||||
category: Category name ('körper', 'ernährung', 'aktivität', 'recovery', 'vitalwerte', 'mental', 'lebensstil')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Progress score 0-100 or None if no data
|
|
||||||
"""
|
|
||||||
# Map category to score calculation functions
|
|
||||||
category_scores = {
|
|
||||||
'körper': 'body_progress_score',
|
|
||||||
'ernährung': 'nutrition_score',
|
|
||||||
'aktivität': 'activity_score',
|
|
||||||
'recovery': 'recovery_score',
|
|
||||||
'vitalwerte': 'recovery_score', # Use recovery score as proxy for vitals
|
|
||||||
'mental': 'recovery_score', # Use recovery score as proxy for mental (sleep quality)
|
|
||||||
'lebensstil': 'data_quality_score', # Use data quality as proxy for lifestyle consistency
|
|
||||||
}
|
|
||||||
|
|
||||||
score_func_name = category_scores.get(category.lower())
|
|
||||||
if not score_func_name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Call the appropriate score function
|
|
||||||
if score_func_name == 'body_progress_score':
|
|
||||||
from data_layer.body_metrics import calculate_body_progress_score
|
|
||||||
return calculate_body_progress_score(profile_id)
|
|
||||||
elif score_func_name == 'nutrition_score':
|
|
||||||
from data_layer.nutrition_metrics import calculate_nutrition_score
|
|
||||||
return calculate_nutrition_score(profile_id)
|
|
||||||
elif score_func_name == 'activity_score':
|
|
||||||
from data_layer.activity_metrics import calculate_activity_score
|
|
||||||
return calculate_activity_score(profile_id)
|
|
||||||
elif score_func_name == 'recovery_score':
|
|
||||||
from data_layer.recovery_metrics import calculate_recovery_score_v2
|
|
||||||
return calculate_recovery_score_v2(profile_id)
|
|
||||||
elif score_func_name == 'data_quality_score':
|
|
||||||
return calculate_data_quality_score(profile_id)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
"""
|
|
||||||
Data Layer Utilities
|
|
||||||
|
|
||||||
Shared helper functions for all data layer modules.
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- calculate_confidence(): Determine data quality confidence level
|
|
||||||
- serialize_dates(): Convert Python date objects to ISO strings for JSON
|
|
||||||
- safe_float(): Safe conversion from Decimal/None to float
|
|
||||||
- safe_int(): Safe conversion to int
|
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
|
||||||
Version: 1.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_confidence(
|
|
||||||
data_points: int,
|
|
||||||
days_requested: int,
|
|
||||||
metric_type: str = "general"
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Calculate confidence level based on data availability.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data_points: Number of actual data points available
|
|
||||||
days_requested: Number of days in analysis window
|
|
||||||
metric_type: Type of metric ("general", "correlation", "trend")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Confidence level: "high" | "medium" | "low" | "insufficient"
|
|
||||||
|
|
||||||
Confidence Rules:
|
|
||||||
General (default):
|
|
||||||
- 7d: high >= 4, medium >= 3, low >= 2
|
|
||||||
- 28d: high >= 18, medium >= 12, low >= 8
|
|
||||||
- 90d: high >= 60, medium >= 40, low >= 30
|
|
||||||
|
|
||||||
Correlation:
|
|
||||||
- high >= 28, medium >= 21, low >= 14
|
|
||||||
|
|
||||||
Trend:
|
|
||||||
- high >= 70% of days, medium >= 50%, low >= 30%
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> calculate_confidence(20, 28, "general")
|
|
||||||
'high'
|
|
||||||
>>> calculate_confidence(10, 28, "general")
|
|
||||||
'low'
|
|
||||||
"""
|
|
||||||
if data_points == 0:
|
|
||||||
return "insufficient"
|
|
||||||
|
|
||||||
if metric_type == "correlation":
|
|
||||||
# Correlation needs more paired data points
|
|
||||||
if data_points >= 28:
|
|
||||||
return "high"
|
|
||||||
elif data_points >= 21:
|
|
||||||
return "medium"
|
|
||||||
elif data_points >= 14:
|
|
||||||
return "low"
|
|
||||||
else:
|
|
||||||
return "insufficient"
|
|
||||||
|
|
||||||
elif metric_type == "trend":
|
|
||||||
# Trend analysis based on percentage of days covered
|
|
||||||
coverage = data_points / days_requested if days_requested > 0 else 0
|
|
||||||
|
|
||||||
if coverage >= 0.70:
|
|
||||||
return "high"
|
|
||||||
elif coverage >= 0.50:
|
|
||||||
return "medium"
|
|
||||||
elif coverage >= 0.30:
|
|
||||||
return "low"
|
|
||||||
else:
|
|
||||||
return "insufficient"
|
|
||||||
|
|
||||||
else: # "general"
|
|
||||||
# Different thresholds based on time window
|
|
||||||
if days_requested <= 7:
|
|
||||||
if data_points >= 4:
|
|
||||||
return "high"
|
|
||||||
elif data_points >= 3:
|
|
||||||
return "medium"
|
|
||||||
elif data_points >= 2:
|
|
||||||
return "low"
|
|
||||||
else:
|
|
||||||
return "insufficient"
|
|
||||||
|
|
||||||
elif days_requested < 90:
|
|
||||||
# 8-89 days: Medium-term analysis
|
|
||||||
if data_points >= 18:
|
|
||||||
return "high"
|
|
||||||
elif data_points >= 12:
|
|
||||||
return "medium"
|
|
||||||
elif data_points >= 8:
|
|
||||||
return "low"
|
|
||||||
else:
|
|
||||||
return "insufficient"
|
|
||||||
|
|
||||||
else: # 90+ days: Long-term analysis
|
|
||||||
if data_points >= 60:
|
|
||||||
return "high"
|
|
||||||
elif data_points >= 40:
|
|
||||||
return "medium"
|
|
||||||
elif data_points >= 30:
|
|
||||||
return "low"
|
|
||||||
else:
|
|
||||||
return "insufficient"
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_dates(data: Any) -> Any:
|
|
||||||
"""
|
|
||||||
Convert Python date objects to ISO strings for JSON serialization.
|
|
||||||
|
|
||||||
Recursively walks through dicts, lists, and tuples converting date objects.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Any data structure (dict, list, tuple, or primitive)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Same structure with dates converted to ISO strings
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> serialize_dates({"date": date(2026, 3, 28), "value": 85.0})
|
|
||||||
{"date": "2026-03-28", "value": 85.0}
|
|
||||||
"""
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return {k: serialize_dates(v) for k, v in data.items()}
|
|
||||||
elif isinstance(data, list):
|
|
||||||
return [serialize_dates(item) for item in data]
|
|
||||||
elif isinstance(data, tuple):
|
|
||||||
return tuple(serialize_dates(item) for item in data)
|
|
||||||
elif isinstance(data, date):
|
|
||||||
return data.isoformat()
|
|
||||||
else:
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def safe_float(value: Any, default: float = 0.0) -> float:
|
|
||||||
"""
|
|
||||||
Safely convert value to float.
|
|
||||||
|
|
||||||
Handles Decimal, None, and invalid values.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: Value to convert (can be Decimal, int, float, str, None)
|
|
||||||
default: Default value if conversion fails
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Float value or default
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> safe_float(Decimal('85.5'))
|
|
||||||
85.5
|
|
||||||
>>> safe_float(None)
|
|
||||||
0.0
|
|
||||||
>>> safe_float(None, -1.0)
|
|
||||||
-1.0
|
|
||||||
"""
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
|
|
||||||
try:
|
|
||||||
if isinstance(value, Decimal):
|
|
||||||
return float(value)
|
|
||||||
return float(value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def safe_int(value: Any, default: int = 0) -> int:
|
|
||||||
"""
|
|
||||||
Safely convert value to int.
|
|
||||||
|
|
||||||
Handles Decimal, None, and invalid values.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: Value to convert
|
|
||||||
default: Default value if conversion fails
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Int value or default
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> safe_int(Decimal('42'))
|
|
||||||
42
|
|
||||||
>>> safe_int(None)
|
|
||||||
0
|
|
||||||
"""
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
|
|
||||||
try:
|
|
||||||
if isinstance(value, Decimal):
|
|
||||||
return int(value)
|
|
||||||
return int(value)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_baseline(
|
|
||||||
values: List[float],
|
|
||||||
method: str = "median"
|
|
||||||
) -> float:
|
|
||||||
"""
|
|
||||||
Calculate baseline value from a list of measurements.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
values: List of numeric values
|
|
||||||
method: "median" (default) | "mean" | "trimmed_mean"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Baseline value
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> calculate_baseline([85.0, 84.5, 86.0, 84.8, 85.2])
|
|
||||||
85.0
|
|
||||||
"""
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
if not values:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
if method == "median":
|
|
||||||
return statistics.median(values)
|
|
||||||
elif method == "mean":
|
|
||||||
return statistics.mean(values)
|
|
||||||
elif method == "trimmed_mean":
|
|
||||||
# Remove top/bottom 10%
|
|
||||||
if len(values) < 10:
|
|
||||||
return statistics.mean(values)
|
|
||||||
sorted_vals = sorted(values)
|
|
||||||
trim_count = len(values) // 10
|
|
||||||
trimmed = sorted_vals[trim_count:-trim_count] if trim_count > 0 else sorted_vals
|
|
||||||
return statistics.mean(trimmed) if trimmed else 0.0
|
|
||||||
else:
|
|
||||||
return statistics.median(values) # Default to median
|
|
||||||
|
|
@ -91,113 +91,9 @@ def get_profile_count():
|
||||||
print(f"Error getting profile count: {e}")
|
print(f"Error getting profile count: {e}")
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
def ensure_migration_table():
|
|
||||||
"""Create migration tracking table if it doesn't exist."""
|
|
||||||
try:
|
|
||||||
conn = get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error creating migration table: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_applied_migrations():
|
|
||||||
"""Get list of already applied migrations."""
|
|
||||||
try:
|
|
||||||
conn = get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT filename FROM schema_migrations ORDER BY filename")
|
|
||||||
migrations = [row[0] for row in cur.fetchall()]
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return migrations
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting applied migrations: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def apply_migration(filepath, filename):
|
|
||||||
"""Apply a single migration file."""
|
|
||||||
try:
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
migration_sql = f.read()
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
# Execute migration
|
|
||||||
cur.execute(migration_sql)
|
|
||||||
|
|
||||||
# Record migration
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO schema_migrations (filename) VALUES (%s)",
|
|
||||||
(filename,)
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
print(f" ✓ Applied: {filename}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Failed to apply {filename}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def run_migrations(migrations_dir="/app/migrations"):
|
|
||||||
"""Run all pending migrations."""
|
|
||||||
import glob
|
|
||||||
import re
|
|
||||||
|
|
||||||
if not os.path.exists(migrations_dir):
|
|
||||||
print("✓ No migrations directory found")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Ensure migration tracking table exists
|
|
||||||
if not ensure_migration_table():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Get already applied migrations
|
|
||||||
applied = get_applied_migrations()
|
|
||||||
|
|
||||||
# Get all migration files (only numbered migrations like 001_*.sql)
|
|
||||||
all_files = sorted(glob.glob(os.path.join(migrations_dir, "*.sql")))
|
|
||||||
migration_pattern = re.compile(r'^\d{3}_.*\.sql$')
|
|
||||||
migration_files = [f for f in all_files if migration_pattern.match(os.path.basename(f))]
|
|
||||||
|
|
||||||
if not migration_files:
|
|
||||||
print("✓ No migration files found")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Apply pending migrations
|
|
||||||
pending = []
|
|
||||||
for filepath in migration_files:
|
|
||||||
filename = os.path.basename(filepath)
|
|
||||||
if filename not in applied:
|
|
||||||
pending.append((filepath, filename))
|
|
||||||
|
|
||||||
if not pending:
|
|
||||||
print(f"✓ All {len(applied)} migrations already applied")
|
|
||||||
return True
|
|
||||||
|
|
||||||
print(f" Found {len(pending)} pending migration(s)...")
|
|
||||||
for filepath, filename in pending:
|
|
||||||
if not apply_migration(filepath, filename):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("═══════════════════════════════════════════════════════════")
|
print("═══════════════════════════════════════════════════════════")
|
||||||
print("MITAI JINKENDO - Database Initialization (v9c)")
|
print("MITAI JINKENDO - Database Initialization (v9b)")
|
||||||
print("═══════════════════════════════════════════════════════════")
|
print("═══════════════════════════════════════════════════════════")
|
||||||
|
|
||||||
# Wait for PostgreSQL
|
# Wait for PostgreSQL
|
||||||
|
|
@ -213,12 +109,6 @@ if __name__ == "__main__":
|
||||||
else:
|
else:
|
||||||
print("✓ Schema already exists")
|
print("✓ Schema already exists")
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
print("\nRunning database migrations...")
|
|
||||||
if not run_migrations():
|
|
||||||
print("✗ Migration failed")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Check for migration
|
# Check for migration
|
||||||
print("\nChecking for SQLite data migration...")
|
print("\nChecking for SQLite data migration...")
|
||||||
sqlite_db = "/app/data/bodytrack.db"
|
sqlite_db = "/app/data/bodytrack.db"
|
||||||
|
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
"""
|
|
||||||
Training Type Profiles - Helper Functions
|
|
||||||
Utilities for loading parameters, profiles, and running evaluations.
|
|
||||||
|
|
||||||
Issue: #15
|
|
||||||
Date: 2026-03-23
|
|
||||||
"""
|
|
||||||
from typing import Dict, Optional, List
|
|
||||||
from decimal import Decimal
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from db import get_cursor
|
|
||||||
from profile_evaluator import TrainingProfileEvaluator
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_decimals(obj):
|
|
||||||
"""
|
|
||||||
Recursively converts Decimal objects to float for JSON serialization.
|
|
||||||
|
|
||||||
PostgreSQL returns numeric values as Decimal, but psycopg2.Json() can't serialize them.
|
|
||||||
"""
|
|
||||||
if isinstance(obj, Decimal):
|
|
||||||
return float(obj)
|
|
||||||
elif isinstance(obj, dict):
|
|
||||||
return {k: convert_decimals(v) for k, v in obj.items()}
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
return [convert_decimals(item) for item in obj]
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def load_parameters_registry(cur) -> Dict[str, Dict]:
|
|
||||||
"""
|
|
||||||
Loads training parameters registry from database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping parameter_key -> config
|
|
||||||
"""
|
|
||||||
cur.execute("""
|
|
||||||
SELECT key, name_de, name_en, category, data_type, unit,
|
|
||||||
description_de, source_field, validation_rules
|
|
||||||
FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
""")
|
|
||||||
|
|
||||||
registry = {}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
registry[row['key']] = dict(row)
|
|
||||||
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
def load_training_type_profile(cur, training_type_id: int) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Loads training type profile for a given type ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Profile JSONB or None if not configured
|
|
||||||
"""
|
|
||||||
cur.execute(
|
|
||||||
"SELECT profile FROM training_types WHERE id = %s",
|
|
||||||
(training_type_id,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if row and row['profile']:
|
|
||||||
return row['profile']
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_evaluation_context(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
activity_date: str,
|
|
||||||
lookback_days: int = 30
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Loads context data for evaluation (user profile + recent activities).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cur: Database cursor
|
|
||||||
profile_id: User profile ID
|
|
||||||
activity_date: Date of activity being evaluated
|
|
||||||
lookback_days: How many days of history to load
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"user_profile": {...},
|
|
||||||
"recent_activities": [...],
|
|
||||||
"historical_activities": [...]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# Load user profile
|
|
||||||
cur.execute(
|
|
||||||
"SELECT hf_max, sleep_goal_minutes FROM profiles WHERE id = %s",
|
|
||||||
(profile_id,)
|
|
||||||
)
|
|
||||||
user_row = cur.fetchone()
|
|
||||||
user_profile = dict(user_row) if user_row else {}
|
|
||||||
|
|
||||||
# Load recent activities (last N days)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, date, training_type_id, duration_min, hr_avg, hr_max,
|
|
||||||
distance_km, kcal_active, rpe
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= %s::date - INTERVAL '%s days'
|
|
||||||
AND date < %s::date
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 50
|
|
||||||
""", (profile_id, activity_date, lookback_days, activity_date))
|
|
||||||
|
|
||||||
recent_activities = [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Historical activities (same for MVP)
|
|
||||||
historical_activities = recent_activities
|
|
||||||
|
|
||||||
return {
|
|
||||||
"user_profile": user_profile,
|
|
||||||
"recent_activities": recent_activities,
|
|
||||||
"historical_activities": historical_activities
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_and_save_activity(
|
|
||||||
cur,
|
|
||||||
activity_id: str,
|
|
||||||
activity_data: Dict,
|
|
||||||
training_type_id: int,
|
|
||||||
profile_id: str
|
|
||||||
) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Evaluates an activity and saves the result to the database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cur: Database cursor
|
|
||||||
activity_id: Activity ID
|
|
||||||
activity_data: Activity data dict
|
|
||||||
training_type_id: Training type ID
|
|
||||||
profile_id: User profile ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Evaluation result or None if no profile configured
|
|
||||||
"""
|
|
||||||
# Load profile
|
|
||||||
profile = load_training_type_profile(cur, training_type_id)
|
|
||||||
if not profile:
|
|
||||||
logger.info(f"[EVALUATION] No profile for training_type {training_type_id}, skipping")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Load parameters registry
|
|
||||||
parameters = load_parameters_registry(cur)
|
|
||||||
|
|
||||||
# Load context
|
|
||||||
context = load_evaluation_context(
|
|
||||||
cur,
|
|
||||||
profile_id,
|
|
||||||
activity_data.get("date"),
|
|
||||||
lookback_days=30
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert Decimal values in activity_data and context
|
|
||||||
activity_data_clean = convert_decimals(activity_data)
|
|
||||||
context_clean = convert_decimals(context)
|
|
||||||
|
|
||||||
# Evaluate
|
|
||||||
evaluator = TrainingProfileEvaluator(parameters)
|
|
||||||
evaluation_result = evaluator.evaluate_activity(
|
|
||||||
activity_data_clean,
|
|
||||||
profile,
|
|
||||||
context_clean
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save to database
|
|
||||||
from psycopg2.extras import Json
|
|
||||||
|
|
||||||
# Convert Decimal to float for JSON serialization
|
|
||||||
evaluation_result_clean = convert_decimals(evaluation_result)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE activity_log
|
|
||||||
SET evaluation = %s,
|
|
||||||
quality_label = %s,
|
|
||||||
overall_score = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""", (
|
|
||||||
Json(evaluation_result_clean),
|
|
||||||
evaluation_result_clean.get("quality_label"),
|
|
||||||
evaluation_result_clean.get("overall_score"),
|
|
||||||
activity_id
|
|
||||||
))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[EVALUATION] Activity {activity_id}: "
|
|
||||||
f"{evaluation_result.get('quality_label')} "
|
|
||||||
f"(score: {evaluation_result.get('overall_score')})"
|
|
||||||
)
|
|
||||||
|
|
||||||
return evaluation_result
|
|
||||||
|
|
||||||
|
|
||||||
def batch_evaluate_activities(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
limit: Optional[int] = None
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Re-evaluates all activities for a user.
|
|
||||||
|
|
||||||
Useful for:
|
|
||||||
- Initial setup after profiles are configured
|
|
||||||
- Re-evaluation after profile changes
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cur: Database cursor
|
|
||||||
profile_id: User profile ID
|
|
||||||
limit: Optional limit for testing
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"total": int,
|
|
||||||
"evaluated": int,
|
|
||||||
"skipped": int,
|
|
||||||
"errors": int
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# Load all activities
|
|
||||||
query = """
|
|
||||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
|
||||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
|
||||||
rpe, pace_min_per_km, cadence, elevation_gain
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
"""
|
|
||||||
params = [profile_id]
|
|
||||||
|
|
||||||
if limit:
|
|
||||||
query += " LIMIT %s"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
cur.execute(query, params)
|
|
||||||
activities = cur.fetchall()
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
"total": len(activities),
|
|
||||||
"evaluated": 0,
|
|
||||||
"skipped": 0,
|
|
||||||
"errors": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Track error details
|
|
||||||
error_details = []
|
|
||||||
|
|
||||||
for activity in activities:
|
|
||||||
activity_dict = dict(activity)
|
|
||||||
try:
|
|
||||||
result = evaluate_and_save_activity(
|
|
||||||
cur,
|
|
||||||
activity_dict["id"],
|
|
||||||
activity_dict,
|
|
||||||
activity_dict["training_type_id"],
|
|
||||||
profile_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
stats["evaluated"] += 1
|
|
||||||
else:
|
|
||||||
stats["skipped"] += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[BATCH-EVAL] Error evaluating {activity_dict['id']}: {e}")
|
|
||||||
error_details.append({
|
|
||||||
"activity_id": activity_dict['id'],
|
|
||||||
"training_type_id": activity_dict.get('training_type_id'),
|
|
||||||
"error": str(e)
|
|
||||||
})
|
|
||||||
stats["errors"] += 1
|
|
||||||
|
|
||||||
# Add error details to stats (limit to first 10)
|
|
||||||
if error_details:
|
|
||||||
stats["error_details"] = error_details[:10]
|
|
||||||
|
|
||||||
logger.info(f"[BATCH-EVAL] Completed: {stats}")
|
|
||||||
return stats
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
"""
|
|
||||||
Feature Usage Logger for Mitai Jinkendo
|
|
||||||
|
|
||||||
Logs all feature access checks to a separate JSON log file for analysis.
|
|
||||||
Phase 2: Non-blocking monitoring of feature usage.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ── Setup Feature Usage Logger ───────────────────────────────────────────────
|
|
||||||
feature_usage_logger = logging.getLogger('feature_usage')
|
|
||||||
feature_usage_logger.setLevel(logging.INFO)
|
|
||||||
feature_usage_logger.propagate = False # Don't propagate to root logger
|
|
||||||
|
|
||||||
# Ensure logs directory exists
|
|
||||||
LOG_DIR = Path('/app/logs')
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# FileHandler for JSON logs
|
|
||||||
log_file = LOG_DIR / 'feature-usage.log'
|
|
||||||
file_handler = logging.FileHandler(log_file)
|
|
||||||
file_handler.setLevel(logging.INFO)
|
|
||||||
file_handler.setFormatter(logging.Formatter('%(message)s')) # JSON only
|
|
||||||
feature_usage_logger.addHandler(file_handler)
|
|
||||||
|
|
||||||
# Also log to console in dev (optional)
|
|
||||||
# console_handler = logging.StreamHandler()
|
|
||||||
# console_handler.setFormatter(logging.Formatter('[FEATURE-USAGE] %(message)s'))
|
|
||||||
# feature_usage_logger.addHandler(console_handler)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Logging Function ──────────────────────────────────────────────────────────
|
|
||||||
def log_feature_usage(user_id: str, feature_id: str, access: dict, action: str):
|
|
||||||
"""
|
|
||||||
Log feature usage in structured JSON format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Profile UUID
|
|
||||||
feature_id: Feature identifier (e.g., 'weight_entries', 'ai_calls')
|
|
||||||
access: Result from check_feature_access() containing:
|
|
||||||
- allowed: bool
|
|
||||||
- limit: int | None
|
|
||||||
- used: int
|
|
||||||
- remaining: int | None
|
|
||||||
- reason: str
|
|
||||||
action: Type of action (e.g., 'create', 'export', 'analyze')
|
|
||||||
|
|
||||||
Example log entry:
|
|
||||||
{
|
|
||||||
"timestamp": "2026-03-20T15:30:45.123456",
|
|
||||||
"user_id": "abc-123",
|
|
||||||
"feature": "weight_entries",
|
|
||||||
"action": "create",
|
|
||||||
"used": 5,
|
|
||||||
"limit": 100,
|
|
||||||
"remaining": 95,
|
|
||||||
"allowed": true,
|
|
||||||
"reason": "within_limit"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
entry = {
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"user_id": user_id,
|
|
||||||
"feature": feature_id,
|
|
||||||
"action": action,
|
|
||||||
"used": access.get('used', 0),
|
|
||||||
"limit": access.get('limit'), # None for unlimited
|
|
||||||
"remaining": access.get('remaining'), # None for unlimited
|
|
||||||
"allowed": access.get('allowed', True),
|
|
||||||
"reason": access.get('reason', 'unknown')
|
|
||||||
}
|
|
||||||
|
|
||||||
feature_usage_logger.info(json.dumps(entry))
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Quick Fix: Insert seed data for goal_type_definitions
|
|
||||||
|
|
||||||
This script ONLY inserts the 8 standard goal types.
|
|
||||||
Safe to run multiple times (uses ON CONFLICT DO NOTHING).
|
|
||||||
|
|
||||||
Run inside backend container:
|
|
||||||
docker exec bodytrack-dev-backend-1 python fix_seed_goal_types.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
import os
|
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
|
|
||||||
# Database connection
|
|
||||||
DB_HOST = os.getenv('DB_HOST', 'db')
|
|
||||||
DB_PORT = os.getenv('DB_PORT', '5432')
|
|
||||||
DB_NAME = os.getenv('DB_NAME', 'bodytrack')
|
|
||||||
DB_USER = os.getenv('DB_USER', 'bodytrack')
|
|
||||||
DB_PASS = os.getenv('DB_PASSWORD', '')
|
|
||||||
|
|
||||||
SEED_DATA = [
|
|
||||||
{
|
|
||||||
'type_key': 'weight',
|
|
||||||
'label_de': 'Gewicht',
|
|
||||||
'label_en': 'Weight',
|
|
||||||
'unit': 'kg',
|
|
||||||
'icon': '⚖️',
|
|
||||||
'category': 'body',
|
|
||||||
'source_table': 'weight_log',
|
|
||||||
'source_column': 'weight',
|
|
||||||
'aggregation_method': 'latest',
|
|
||||||
'description': 'Aktuelles Körpergewicht',
|
|
||||||
'is_system': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type_key': 'body_fat',
|
|
||||||
'label_de': 'Körperfett',
|
|
||||||
'label_en': 'Body Fat',
|
|
||||||
'unit': '%',
|
|
||||||
'icon': '📊',
|
|
||||||
'category': 'body',
|
|
||||||
'source_table': 'caliper_log',
|
|
||||||
'source_column': 'body_fat_pct',
|
|
||||||
'aggregation_method': 'latest',
|
|
||||||
'description': 'Körperfettanteil aus Caliper-Messung',
|
|
||||||
'is_system': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type_key': 'lean_mass',
|
|
||||||
'label_de': 'Muskelmasse',
|
|
||||||
'label_en': 'Lean Mass',
|
|
||||||
'unit': 'kg',
|
|
||||||
'icon': '💪',
|
|
||||||
'category': 'body',
|
|
||||||
'calculation_formula': '{"type": "lean_mass", "dependencies": ["weight_log.weight", "caliper_log.body_fat_pct"], "formula": "weight - (weight * body_fat_pct / 100)"}',
|
|
||||||
'description': 'Fettfreie Körpermasse (berechnet aus Gewicht und Körperfett)',
|
|
||||||
'is_system': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type_key': 'vo2max',
|
|
||||||
'label_de': 'VO2Max',
|
|
||||||
'label_en': 'VO2Max',
|
|
||||||
'unit': 'ml/kg/min',
|
|
||||||
'icon': '🫁',
|
|
||||||
'category': 'recovery',
|
|
||||||
'source_table': 'vitals_baseline',
|
|
||||||
'source_column': 'vo2_max',
|
|
||||||
'aggregation_method': 'latest',
|
|
||||||
'description': 'Maximale Sauerstoffaufnahme (geschätzt oder gemessen)',
|
|
||||||
'is_system': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type_key': 'rhr',
|
|
||||||
'label_de': 'Ruhepuls',
|
|
||||||
'label_en': 'Resting Heart Rate',
|
|
||||||
'unit': 'bpm',
|
|
||||||
'icon': '💓',
|
|
||||||
'category': 'recovery',
|
|
||||||
'source_table': 'vitals_baseline',
|
|
||||||
'source_column': 'resting_hr',
|
|
||||||
'aggregation_method': 'latest',
|
|
||||||
'description': 'Ruhepuls morgens vor dem Aufstehen',
|
|
||||||
'is_system': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type_key': 'bp',
|
|
||||||
'label_de': 'Blutdruck',
|
|
||||||
'label_en': 'Blood Pressure',
|
|
||||||
'unit': 'mmHg',
|
|
||||||
'icon': '❤️',
|
|
||||||
'category': 'recovery',
|
|
||||||
'source_table': 'blood_pressure_log',
|
|
||||||
'source_column': 'systolic',
|
|
||||||
'aggregation_method': 'latest',
|
|
||||||
'description': 'Blutdruck (aktuell nur systolisch, v2.0: beide Werte)',
|
|
||||||
'is_system': True
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type_key': 'strength',
|
|
||||||
'label_de': 'Kraft',
|
|
||||||
'label_en': 'Strength',
|
|
||||||
'unit': 'kg',
|
|
||||||
'icon': '🏋️',
|
|
||||||
'category': 'activity',
|
|
||||||
'description': 'Maximalkraft (Platzhalter, Datenquelle in v2.0)',
|
|
||||||
'is_system': True,
|
|
||||||
'is_active': False
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'type_key': 'flexibility',
|
|
||||||
'label_de': 'Beweglichkeit',
|
|
||||||
'label_en': 'Flexibility',
|
|
||||||
'unit': 'cm',
|
|
||||||
'icon': '🤸',
|
|
||||||
'category': 'activity',
|
|
||||||
'description': 'Beweglichkeit (Platzhalter, Datenquelle in v2.0)',
|
|
||||||
'is_system': True,
|
|
||||||
'is_active': False
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 70)
|
|
||||||
print("Goal Type Definitions - Seed Data Fix")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# Connect to database
|
|
||||||
conn = psycopg2.connect(
|
|
||||||
host=DB_HOST,
|
|
||||||
port=DB_PORT,
|
|
||||||
dbname=DB_NAME,
|
|
||||||
user=DB_USER,
|
|
||||||
password=DB_PASS
|
|
||||||
)
|
|
||||||
conn.autocommit = False
|
|
||||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check current state
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions")
|
|
||||||
before_count = cur.fetchone()['count']
|
|
||||||
print(f"\nBefore: {before_count} goal types in database")
|
|
||||||
|
|
||||||
# Insert seed data
|
|
||||||
print(f"\nInserting {len(SEED_DATA)} standard goal types...")
|
|
||||||
inserted = 0
|
|
||||||
skipped = 0
|
|
||||||
|
|
||||||
for data in SEED_DATA:
|
|
||||||
columns = list(data.keys())
|
|
||||||
values = [data[col] for col in columns]
|
|
||||||
placeholders = ', '.join(['%s'] * len(values))
|
|
||||||
cols_str = ', '.join(columns)
|
|
||||||
|
|
||||||
sql = f"""
|
|
||||||
INSERT INTO goal_type_definitions ({cols_str})
|
|
||||||
VALUES ({placeholders})
|
|
||||||
ON CONFLICT (type_key) DO NOTHING
|
|
||||||
RETURNING id
|
|
||||||
"""
|
|
||||||
|
|
||||||
cur.execute(sql, values)
|
|
||||||
result = cur.fetchone()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
inserted += 1
|
|
||||||
print(f" ✓ {data['type_key']}: {data['label_de']}")
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
print(f" - {data['type_key']}: already exists (skipped)")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# Check final state
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM goal_type_definitions")
|
|
||||||
after_count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
print(f"\nAfter: {after_count} goal types in database")
|
|
||||||
print(f" Inserted: {inserted}")
|
|
||||||
print(f" Skipped: {skipped}")
|
|
||||||
|
|
||||||
# Show summary
|
|
||||||
cur.execute("""
|
|
||||||
SELECT type_key, label_de, is_active, is_system
|
|
||||||
FROM goal_type_definitions
|
|
||||||
ORDER BY is_system DESC, type_key
|
|
||||||
""")
|
|
||||||
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("Current Goal Types:")
|
|
||||||
print("=" * 70)
|
|
||||||
print(f"\n{'Type Key':<20} {'Label':<20} {'System':<8} {'Active':<8}")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
for row in cur.fetchall():
|
|
||||||
status = "YES" if row['is_system'] else "NO"
|
|
||||||
active = "YES" if row['is_active'] else "NO"
|
|
||||||
print(f"{row['type_key']:<20} {row['label_de']:<20} {status:<8} {active:<8}")
|
|
||||||
|
|
||||||
print("\n✅ DONE! Goal types seeded successfully.")
|
|
||||||
print("\nNext step: Reload frontend to see the changes.")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
print(f"\n❌ Error: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -1,396 +0,0 @@
|
||||||
"""
|
|
||||||
Script to generate complete metadata for all 116 placeholders.
|
|
||||||
|
|
||||||
This script combines:
|
|
||||||
1. Automatic extraction from PLACEHOLDER_MAP
|
|
||||||
2. Manual curation of known metadata
|
|
||||||
3. Gap identification for unresolved fields
|
|
||||||
|
|
||||||
Output: Complete metadata JSON ready for export
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add backend to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
from placeholder_metadata import (
|
|
||||||
PlaceholderMetadata,
|
|
||||||
PlaceholderType,
|
|
||||||
TimeWindow,
|
|
||||||
OutputType,
|
|
||||||
SourceInfo,
|
|
||||||
ConfidenceLogic,
|
|
||||||
ConfidenceLevel,
|
|
||||||
METADATA_REGISTRY
|
|
||||||
)
|
|
||||||
from placeholder_metadata_extractor import build_complete_metadata_registry
|
|
||||||
|
|
||||||
|
|
||||||
# ── Manual Metadata Corrections ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def apply_manual_corrections(registry):
|
|
||||||
"""
|
|
||||||
Apply manual corrections to automatically extracted metadata.
|
|
||||||
|
|
||||||
This ensures 100% accuracy for fields that cannot be reliably extracted.
|
|
||||||
"""
|
|
||||||
corrections = {
|
|
||||||
# ── Profil ────────────────────────────────────────────────────────────
|
|
||||||
"name": {
|
|
||||||
"semantic_contract": "Name des Profils aus der Datenbank, keine Transformation",
|
|
||||||
},
|
|
||||||
"age": {
|
|
||||||
"semantic_contract": "Berechnet aus Geburtsdatum (dob) im Profil via calculate_age()",
|
|
||||||
"unit": "Jahre",
|
|
||||||
},
|
|
||||||
"height": {
|
|
||||||
"semantic_contract": "Körpergröße aus Profil in cm, unverändert",
|
|
||||||
},
|
|
||||||
"geschlecht": {
|
|
||||||
"semantic_contract": "Geschlecht aus Profil: m='männlich', w='weiblich'",
|
|
||||||
"output_type": OutputType.ENUM,
|
|
||||||
},
|
|
||||||
|
|
||||||
# ── Körper ────────────────────────────────────────────────────────────
|
|
||||||
"weight_aktuell": {
|
|
||||||
"semantic_contract": "Letzter verfügbarer Gewichtseintrag aus weight_log, keine Mittelung oder Glättung",
|
|
||||||
"confidence_logic": ConfidenceLogic(
|
|
||||||
supported=True,
|
|
||||||
calculation="Confidence = 'high' if data exists, else 'insufficient'",
|
|
||||||
thresholds={"min_data_points": 1},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"weight_trend": {
|
|
||||||
"semantic_contract": "Gewichtstrend-Beschreibung über 28 Tage: stabil, steigend (+X kg), sinkend (-X kg)",
|
|
||||||
"known_issues": ["time_window_inconsistent: Description says 7d/30d, implementation uses 28d"],
|
|
||||||
"notes": ["Consider splitting into weight_trend_7d and weight_trend_28d"],
|
|
||||||
},
|
|
||||||
"kf_aktuell": {
|
|
||||||
"semantic_contract": "Letzter berechneter Körperfettanteil aus caliper_log (JPL-7 oder JPL-3 Formel)",
|
|
||||||
},
|
|
||||||
"caliper_summary": {
|
|
||||||
"semantic_contract": "Strukturierte Zusammenfassung der letzten Caliper-Messungen mit Körperfettanteil und Methode",
|
|
||||||
"notes": ["Returns formatted text summary, not JSON"],
|
|
||||||
},
|
|
||||||
"circ_summary": {
|
|
||||||
"semantic_contract": "Best-of-Each Strategie: neueste Messung pro Körperstelle mit Altersangabe in Tagen",
|
|
||||||
"time_window": TimeWindow.MIXED,
|
|
||||||
"notes": ["Different body parts may have different timestamps"],
|
|
||||||
},
|
|
||||||
"recomposition_quadrant": {
|
|
||||||
"semantic_contract": "Klassifizierung basierend auf FM/LBM Änderungen: Optimal Recomposition (FM↓ LBM↑), Fat Loss (FM↓ LBM→), Muscle Gain (FM→ LBM↑), Weight Gain (FM↑ LBM↑)",
|
|
||||||
"type": PlaceholderType.INTERPRETED,
|
|
||||||
},
|
|
||||||
|
|
||||||
# ── Ernährung ─────────────────────────────────────────────────────────
|
|
||||||
"kcal_avg": {
|
|
||||||
"semantic_contract": "Durchschnittliche Kalorienaufnahme über 30 Tage aus nutrition_log",
|
|
||||||
},
|
|
||||||
"protein_avg": {
|
|
||||||
"semantic_contract": "Durchschnittliche Proteinaufnahme in g über 30 Tage aus nutrition_log",
|
|
||||||
},
|
|
||||||
"carb_avg": {
|
|
||||||
"semantic_contract": "Durchschnittliche Kohlenhydrataufnahme in g über 30 Tage aus nutrition_log",
|
|
||||||
},
|
|
||||||
"fat_avg": {
|
|
||||||
"semantic_contract": "Durchschnittliche Fettaufnahme in g über 30 Tage aus nutrition_log",
|
|
||||||
},
|
|
||||||
"nutrition_days": {
|
|
||||||
"semantic_contract": "Anzahl der Tage mit Ernährungsdaten in den letzten 30 Tagen",
|
|
||||||
"output_type": OutputType.INTEGER,
|
|
||||||
},
|
|
||||||
"protein_ziel_low": {
|
|
||||||
"semantic_contract": "Untere Grenze der Protein-Zielspanne (1.6 g/kg Körpergewicht)",
|
|
||||||
},
|
|
||||||
"protein_ziel_high": {
|
|
||||||
"semantic_contract": "Obere Grenze der Protein-Zielspanne (2.2 g/kg Körpergewicht)",
|
|
||||||
},
|
|
||||||
"protein_g_per_kg": {
|
|
||||||
"semantic_contract": "Aktuelle Proteinaufnahme normiert auf kg Körpergewicht (protein_avg / weight)",
|
|
||||||
},
|
|
||||||
|
|
||||||
# ── Training ──────────────────────────────────────────────────────────
|
|
||||||
"activity_summary": {
|
|
||||||
"semantic_contract": "Strukturierte Zusammenfassung der Trainingsaktivität der letzten 7 Tage",
|
|
||||||
"type": PlaceholderType.RAW_DATA,
|
|
||||||
"known_issues": ["time_window_ambiguous: Function name suggests variable window, actual implementation unclear"],
|
|
||||||
},
|
|
||||||
"activity_detail": {
|
|
||||||
"semantic_contract": "Detaillierte Liste aller Trainingseinheiten mit Typ, Dauer, Intensität",
|
|
||||||
"type": PlaceholderType.RAW_DATA,
|
|
||||||
"known_issues": ["time_window_ambiguous: No clear time window specified"],
|
|
||||||
},
|
|
||||||
"trainingstyp_verteilung": {
|
|
||||||
"semantic_contract": "Verteilung der Trainingstypen über einen Zeitraum (Anzahl Sessions pro Typ)",
|
|
||||||
"type": PlaceholderType.RAW_DATA,
|
|
||||||
},
|
|
||||||
|
|
||||||
# ── Zeitraum ──────────────────────────────────────────────────────────
|
|
||||||
"datum_heute": {
|
|
||||||
"semantic_contract": "Aktuelles Datum im Format YYYY-MM-DD",
|
|
||||||
"output_type": OutputType.DATE,
|
|
||||||
"format_hint": "2026-03-29",
|
|
||||||
},
|
|
||||||
"zeitraum_7d": {
|
|
||||||
"semantic_contract": "Zeitraum der letzten 7 Tage als Text",
|
|
||||||
"format_hint": "letzte 7 Tage (2026-03-22 bis 2026-03-29)",
|
|
||||||
},
|
|
||||||
"zeitraum_30d": {
|
|
||||||
"semantic_contract": "Zeitraum der letzten 30 Tage als Text",
|
|
||||||
"format_hint": "letzte 30 Tage (2026-02-27 bis 2026-03-29)",
|
|
||||||
},
|
|
||||||
"zeitraum_90d": {
|
|
||||||
"semantic_contract": "Zeitraum der letzten 90 Tage als Text",
|
|
||||||
"format_hint": "letzte 90 Tage (2025-12-29 bis 2026-03-29)",
|
|
||||||
},
|
|
||||||
|
|
||||||
# ── Goals & Focus ─────────────────────────────────────────────────────
|
|
||||||
"active_goals_json": {
|
|
||||||
"type": PlaceholderType.RAW_DATA,
|
|
||||||
"output_type": OutputType.JSON,
|
|
||||||
"semantic_contract": "JSON-Array aller aktiven Ziele mit vollständigen Details",
|
|
||||||
},
|
|
||||||
"active_goals_md": {
|
|
||||||
"type": PlaceholderType.RAW_DATA,
|
|
||||||
"output_type": OutputType.MARKDOWN,
|
|
||||||
"semantic_contract": "Markdown-formatierte Liste aller aktiven Ziele",
|
|
||||||
},
|
|
||||||
"focus_areas_weighted_json": {
|
|
||||||
"type": PlaceholderType.RAW_DATA,
|
|
||||||
"output_type": OutputType.JSON,
|
|
||||||
"semantic_contract": "JSON-Array der gewichteten Focus Areas mit Progress",
|
|
||||||
},
|
|
||||||
"top_3_goals_behind_schedule": {
|
|
||||||
"type": PlaceholderType.INTERPRETED,
|
|
||||||
"semantic_contract": "Top 3 Ziele mit größter negativer Abweichung vom Zeitplan (Zeit-basiert)",
|
|
||||||
},
|
|
||||||
"top_3_goals_on_track": {
|
|
||||||
"type": PlaceholderType.INTERPRETED,
|
|
||||||
"semantic_contract": "Top 3 Ziele mit größter positiver Abweichung vom Zeitplan oder am besten im Plan",
|
|
||||||
},
|
|
||||||
|
|
||||||
# ── Scores ────────────────────────────────────────────────────────────
|
|
||||||
"goal_progress_score": {
|
|
||||||
"type": PlaceholderType.ATOMIC,
|
|
||||||
"semantic_contract": "Gewichteter Durchschnitts-Fortschritt aller aktiven Ziele (0-100)",
|
|
||||||
"unit": "%",
|
|
||||||
"output_type": OutputType.INTEGER,
|
|
||||||
},
|
|
||||||
"body_progress_score": {
|
|
||||||
"type": PlaceholderType.ATOMIC,
|
|
||||||
"semantic_contract": "Body Progress Score basierend auf Gewicht/KFA-Ziel-Erreichung (0-100)",
|
|
||||||
"unit": "%",
|
|
||||||
"output_type": OutputType.INTEGER,
|
|
||||||
},
|
|
||||||
"nutrition_score": {
|
|
||||||
"type": PlaceholderType.ATOMIC,
|
|
||||||
"semantic_contract": "Nutrition Score basierend auf Protein Adequacy, Makro-Konsistenz (0-100)",
|
|
||||||
"unit": "%",
|
|
||||||
"output_type": OutputType.INTEGER,
|
|
||||||
},
|
|
||||||
"activity_score": {
|
|
||||||
"type": PlaceholderType.ATOMIC,
|
|
||||||
"semantic_contract": "Activity Score basierend auf Trainingsfrequenz, Qualitätssessions (0-100)",
|
|
||||||
"unit": "%",
|
|
||||||
"output_type": OutputType.INTEGER,
|
|
||||||
},
|
|
||||||
"recovery_score": {
|
|
||||||
"type": PlaceholderType.ATOMIC,
|
|
||||||
"semantic_contract": "Recovery Score basierend auf Schlaf, HRV, Ruhepuls (0-100)",
|
|
||||||
"unit": "%",
|
|
||||||
"output_type": OutputType.INTEGER,
|
|
||||||
},
|
|
||||||
|
|
||||||
# ── Correlations ──────────────────────────────────────────────────────
|
|
||||||
"correlation_energy_weight_lag": {
|
|
||||||
"type": PlaceholderType.INTERPRETED,
|
|
||||||
"output_type": OutputType.JSON,
|
|
||||||
"semantic_contract": "Lag-Korrelation zwischen Energiebilanz und Gewichtsänderung (3d/7d/14d)",
|
|
||||||
},
|
|
||||||
"correlation_protein_lbm": {
|
|
||||||
"type": PlaceholderType.INTERPRETED,
|
|
||||||
"output_type": OutputType.JSON,
|
|
||||||
"semantic_contract": "Korrelation zwischen Proteinaufnahme und Magermasse-Änderung",
|
|
||||||
},
|
|
||||||
"plateau_detected": {
|
|
||||||
"type": PlaceholderType.INTERPRETED,
|
|
||||||
"output_type": OutputType.JSON,
|
|
||||||
"semantic_contract": "Plateau-Erkennung: Gewichtsstagnation trotz Kaloriendefizit",
|
|
||||||
},
|
|
||||||
"top_drivers": {
|
|
||||||
"type": PlaceholderType.INTERPRETED,
|
|
||||||
"output_type": OutputType.JSON,
|
|
||||||
"semantic_contract": "Top Einflussfaktoren auf Ziel-Fortschritt (sortiert nach Impact)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, updates in corrections.items():
|
|
||||||
metadata = registry.get(key)
|
|
||||||
if metadata:
|
|
||||||
for field, value in updates.items():
|
|
||||||
setattr(metadata, field, value)
|
|
||||||
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
def export_complete_metadata(registry, output_path: str = None):
|
|
||||||
"""
|
|
||||||
Export complete metadata to JSON file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
registry: PlaceholderMetadataRegistry
|
|
||||||
output_path: Optional output file path
|
|
||||||
"""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
|
|
||||||
# Convert to dict
|
|
||||||
export_data = {
|
|
||||||
"schema_version": "1.0.0",
|
|
||||||
"generated_at": "2026-03-29T12:00:00Z",
|
|
||||||
"total_placeholders": len(all_metadata),
|
|
||||||
"placeholders": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, metadata in all_metadata.items():
|
|
||||||
export_data["placeholders"][key] = metadata.to_dict()
|
|
||||||
|
|
||||||
# Write to file
|
|
||||||
if not output_path:
|
|
||||||
output_path = Path(__file__).parent.parent / "docs" / "placeholder_metadata_complete.json"
|
|
||||||
|
|
||||||
output_path = Path(output_path)
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
print(f"✓ Exported complete metadata to: {output_path}")
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
def generate_gap_report(registry):
|
|
||||||
"""
|
|
||||||
Generate gap report showing unresolved metadata fields.
|
|
||||||
"""
|
|
||||||
gaps = {
|
|
||||||
"unknown_time_window": [],
|
|
||||||
"unknown_output_type": [],
|
|
||||||
"legacy_unknown_type": [],
|
|
||||||
"missing_semantic_contract": [],
|
|
||||||
"missing_data_layer_module": [],
|
|
||||||
"missing_source_tables": [],
|
|
||||||
"validation_issues": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, metadata in registry.get_all().items():
|
|
||||||
if metadata.time_window == TimeWindow.UNKNOWN:
|
|
||||||
gaps["unknown_time_window"].append(key)
|
|
||||||
if metadata.output_type == OutputType.UNKNOWN:
|
|
||||||
gaps["unknown_output_type"].append(key)
|
|
||||||
if metadata.type == PlaceholderType.LEGACY_UNKNOWN:
|
|
||||||
gaps["legacy_unknown_type"].append(key)
|
|
||||||
if not metadata.semantic_contract or metadata.semantic_contract == metadata.description:
|
|
||||||
gaps["missing_semantic_contract"].append(key)
|
|
||||||
if not metadata.source.data_layer_module:
|
|
||||||
gaps["missing_data_layer_module"].append(key)
|
|
||||||
if not metadata.source.source_tables:
|
|
||||||
gaps["missing_source_tables"].append(key)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
violations = registry.validate_all()
|
|
||||||
for key, issues in violations.items():
|
|
||||||
error_count = len([i for i in issues if i.severity == "error"])
|
|
||||||
if error_count > 0:
|
|
||||||
gaps["validation_issues"].append(key)
|
|
||||||
|
|
||||||
return gaps
|
|
||||||
|
|
||||||
|
|
||||||
def print_summary(registry, gaps):
|
|
||||||
"""Print summary statistics."""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
total = len(all_metadata)
|
|
||||||
|
|
||||||
# Count by type
|
|
||||||
by_type = {}
|
|
||||||
for metadata in all_metadata.values():
|
|
||||||
ptype = metadata.type.value
|
|
||||||
by_type[ptype] = by_type.get(ptype, 0) + 1
|
|
||||||
|
|
||||||
# Count by category
|
|
||||||
by_category = {}
|
|
||||||
for metadata in all_metadata.values():
|
|
||||||
cat = metadata.category
|
|
||||||
by_category[cat] = by_category.get(cat, 0) + 1
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("PLACEHOLDER METADATA EXTRACTION SUMMARY")
|
|
||||||
print("="*60)
|
|
||||||
print(f"\nTotal Placeholders: {total}")
|
|
||||||
print(f"\nBy Type:")
|
|
||||||
for ptype, count in sorted(by_type.items()):
|
|
||||||
print(f" {ptype:20} {count:3} ({count/total*100:5.1f}%)")
|
|
||||||
|
|
||||||
print(f"\nBy Category:")
|
|
||||||
for cat, count in sorted(by_category.items()):
|
|
||||||
print(f" {cat:20} {count:3} ({count/total*100:5.1f}%)")
|
|
||||||
|
|
||||||
print(f"\nGaps & Unresolved Fields:")
|
|
||||||
for gap_type, placeholders in gaps.items():
|
|
||||||
if placeholders:
|
|
||||||
print(f" {gap_type:30} {len(placeholders):3} placeholders")
|
|
||||||
|
|
||||||
# Coverage score
|
|
||||||
gap_count = sum(len(v) for v in gaps.values())
|
|
||||||
coverage = (1 - gap_count / (total * 6)) * 100 # 6 gap types
|
|
||||||
print(f"\n Metadata Coverage: {coverage:5.1f}%")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main execution function."""
|
|
||||||
print("Building complete placeholder metadata registry...")
|
|
||||||
print("(This requires database access)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build registry with automatic extraction
|
|
||||||
registry = build_complete_metadata_registry()
|
|
||||||
|
|
||||||
# Apply manual corrections
|
|
||||||
print("\nApplying manual corrections...")
|
|
||||||
registry = apply_manual_corrections(registry)
|
|
||||||
|
|
||||||
# Generate gap report
|
|
||||||
print("\nGenerating gap report...")
|
|
||||||
gaps = generate_gap_report(registry)
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
print_summary(registry, gaps)
|
|
||||||
|
|
||||||
# Export to JSON
|
|
||||||
print("\nExporting complete metadata...")
|
|
||||||
output_path = export_complete_metadata(registry)
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("✓ COMPLETE")
|
|
||||||
print("="*60)
|
|
||||||
print(f"\nNext steps:")
|
|
||||||
print(f"1. Review gaps in gap report")
|
|
||||||
print(f"2. Manually fill remaining unresolved fields")
|
|
||||||
print(f"3. Run validation: python -m backend.placeholder_metadata_complete")
|
|
||||||
print(f"4. Generate catalog files: python -m backend.generate_placeholder_catalog")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n✗ ERROR: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
"""
|
|
||||||
Complete Metadata Generation V2 - Quality Assured
|
|
||||||
|
|
||||||
This version applies strict quality controls and enhanced extraction logic.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
from placeholder_metadata import (
|
|
||||||
PlaceholderType,
|
|
||||||
TimeWindow,
|
|
||||||
OutputType,
|
|
||||||
SourceInfo,
|
|
||||||
QualityFilterPolicy,
|
|
||||||
ConfidenceLogic,
|
|
||||||
METADATA_REGISTRY
|
|
||||||
)
|
|
||||||
from placeholder_metadata_extractor import build_complete_metadata_registry
|
|
||||||
from placeholder_metadata_enhanced import (
|
|
||||||
extract_value_raw,
|
|
||||||
infer_unit_strict,
|
|
||||||
detect_time_window_precise,
|
|
||||||
resolve_real_source,
|
|
||||||
create_activity_quality_policy,
|
|
||||||
create_confidence_logic,
|
|
||||||
calculate_completeness_score
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_enhanced_corrections(registry):
|
|
||||||
"""
|
|
||||||
Apply enhanced corrections with strict quality controls.
|
|
||||||
|
|
||||||
This replaces heuristic guessing with deterministic derivation.
|
|
||||||
"""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
|
|
||||||
for key, metadata in all_metadata.items():
|
|
||||||
unresolved = []
|
|
||||||
|
|
||||||
# ── 1. Fix value_raw ──────────────────────────────────────────────────
|
|
||||||
if metadata.value_display and metadata.value_display not in ['nicht verfügbar', '']:
|
|
||||||
raw_val, success = extract_value_raw(
|
|
||||||
metadata.value_display,
|
|
||||||
metadata.output_type,
|
|
||||||
metadata.type
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
metadata.value_raw = raw_val
|
|
||||||
else:
|
|
||||||
metadata.value_raw = None
|
|
||||||
unresolved.append('value_raw')
|
|
||||||
|
|
||||||
# ── 2. Fix unit (strict) ──────────────────────────────────────────────
|
|
||||||
strict_unit = infer_unit_strict(
|
|
||||||
key,
|
|
||||||
metadata.description,
|
|
||||||
metadata.output_type,
|
|
||||||
metadata.type
|
|
||||||
)
|
|
||||||
# Only overwrite if we have a confident answer or existing is clearly wrong
|
|
||||||
if strict_unit is not None:
|
|
||||||
metadata.unit = strict_unit
|
|
||||||
elif metadata.output_type in [OutputType.JSON, OutputType.MARKDOWN, OutputType.ENUM]:
|
|
||||||
metadata.unit = None # These never have units
|
|
||||||
elif 'score' in key.lower() or 'correlation' in key.lower():
|
|
||||||
metadata.unit = None # Dimensionless
|
|
||||||
|
|
||||||
# ── 3. Fix time_window (precise detection) ────────────────────────────
|
|
||||||
tw, is_certain, mismatch = detect_time_window_precise(
|
|
||||||
key,
|
|
||||||
metadata.description,
|
|
||||||
metadata.source.resolver,
|
|
||||||
metadata.semantic_contract
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_certain:
|
|
||||||
metadata.time_window = tw
|
|
||||||
if mismatch:
|
|
||||||
metadata.legacy_contract_mismatch = True
|
|
||||||
if mismatch not in metadata.known_issues:
|
|
||||||
metadata.known_issues.append(mismatch)
|
|
||||||
else:
|
|
||||||
metadata.time_window = tw
|
|
||||||
if tw == TimeWindow.UNKNOWN:
|
|
||||||
unresolved.append('time_window')
|
|
||||||
else:
|
|
||||||
# Inferred but not certain
|
|
||||||
if mismatch and mismatch not in metadata.notes:
|
|
||||||
metadata.notes.append(f"Time window inferred: {mismatch}")
|
|
||||||
|
|
||||||
# ── 4. Fix source provenance ──────────────────────────────────────────
|
|
||||||
func, dl_module, tables, source_kind = resolve_real_source(metadata.source.resolver)
|
|
||||||
|
|
||||||
if func:
|
|
||||||
metadata.source.function = func
|
|
||||||
if dl_module:
|
|
||||||
metadata.source.data_layer_module = dl_module
|
|
||||||
if tables:
|
|
||||||
metadata.source.source_tables = tables
|
|
||||||
metadata.source.source_kind = source_kind
|
|
||||||
|
|
||||||
if source_kind == "wrapper" or source_kind == "unknown":
|
|
||||||
unresolved.append('source')
|
|
||||||
|
|
||||||
# ── 5. Add quality_filter_policy for activity placeholders ────────────
|
|
||||||
if not metadata.quality_filter_policy:
|
|
||||||
qfp = create_activity_quality_policy(key)
|
|
||||||
if qfp:
|
|
||||||
metadata.quality_filter_policy = qfp
|
|
||||||
|
|
||||||
# ── 6. Add confidence_logic ────────────────────────────────────────────
|
|
||||||
if not metadata.confidence_logic:
|
|
||||||
cl = create_confidence_logic(key, metadata.source.data_layer_module)
|
|
||||||
if cl:
|
|
||||||
metadata.confidence_logic = cl
|
|
||||||
|
|
||||||
# ── 7. Determine provenance_confidence ────────────────────────────────
|
|
||||||
if metadata.source.data_layer_module and metadata.source.source_tables:
|
|
||||||
metadata.provenance_confidence = "high"
|
|
||||||
elif metadata.source.function or metadata.source.source_tables:
|
|
||||||
metadata.provenance_confidence = "medium"
|
|
||||||
else:
|
|
||||||
metadata.provenance_confidence = "low"
|
|
||||||
|
|
||||||
# ── 8. Determine contract_source ───────────────────────────────────────
|
|
||||||
if metadata.semantic_contract and len(metadata.semantic_contract) > 50:
|
|
||||||
metadata.contract_source = "documented"
|
|
||||||
elif metadata.description:
|
|
||||||
metadata.contract_source = "inferred"
|
|
||||||
else:
|
|
||||||
metadata.contract_source = "unknown"
|
|
||||||
|
|
||||||
# ── 9. Check for orphaned placeholders ────────────────────────────────
|
|
||||||
if not metadata.used_by.prompts and not metadata.used_by.pipelines and not metadata.used_by.charts:
|
|
||||||
metadata.orphaned_placeholder = True
|
|
||||||
|
|
||||||
# ── 10. Set unresolved fields ──────────────────────────────────────────
|
|
||||||
metadata.unresolved_fields = unresolved
|
|
||||||
|
|
||||||
# ── 11. Calculate completeness score ───────────────────────────────────
|
|
||||||
metadata.metadata_completeness_score = calculate_completeness_score(metadata.to_dict())
|
|
||||||
|
|
||||||
# ── 12. Set schema status ──────────────────────────────────────────────
|
|
||||||
if metadata.metadata_completeness_score >= 80 and len(unresolved) == 0:
|
|
||||||
metadata.schema_status = "validated"
|
|
||||||
elif metadata.metadata_completeness_score >= 50:
|
|
||||||
metadata.schema_status = "draft"
|
|
||||||
else:
|
|
||||||
metadata.schema_status = "incomplete"
|
|
||||||
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
def generate_qa_report(registry) -> str:
|
|
||||||
"""
|
|
||||||
Generate QA report with quality metrics.
|
|
||||||
"""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
total = len(all_metadata)
|
|
||||||
|
|
||||||
# Collect metrics
|
|
||||||
category_unknown = sum(1 for m in all_metadata.values() if m.category == "Unknown")
|
|
||||||
no_description = sum(1 for m in all_metadata.values() if not m.description or "No description" in m.description)
|
|
||||||
tw_unknown = sum(1 for m in all_metadata.values() if m.time_window == TimeWindow.UNKNOWN)
|
|
||||||
no_quality_filter = sum(1 for m in all_metadata.values() if not m.quality_filter_policy and 'activity' in m.key.lower())
|
|
||||||
no_confidence = sum(1 for m in all_metadata.values() if not m.confidence_logic and m.source.data_layer_module)
|
|
||||||
legacy_mismatch = sum(1 for m in all_metadata.values() if m.legacy_contract_mismatch)
|
|
||||||
orphaned = sum(1 for m in all_metadata.values() if m.orphaned_placeholder)
|
|
||||||
|
|
||||||
# Find problematic placeholders
|
|
||||||
problematic = []
|
|
||||||
for key, m in all_metadata.items():
|
|
||||||
score = m.metadata_completeness_score
|
|
||||||
unresolved_count = len(m.unresolved_fields)
|
|
||||||
issues_count = len(m.known_issues)
|
|
||||||
|
|
||||||
problem_score = (100 - score) + (unresolved_count * 10) + (issues_count * 5)
|
|
||||||
if problem_score > 0:
|
|
||||||
problematic.append((key, problem_score, score, unresolved_count, issues_count))
|
|
||||||
|
|
||||||
problematic.sort(key=lambda x: x[1], reverse=True)
|
|
||||||
|
|
||||||
# Build report
|
|
||||||
lines = [
|
|
||||||
"# Placeholder Metadata QA Report",
|
|
||||||
"",
|
|
||||||
f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
||||||
f"**Total Placeholders:** {total}",
|
|
||||||
"",
|
|
||||||
"## Quality Metrics",
|
|
||||||
"",
|
|
||||||
f"- **Category Unknown:** {category_unknown} ({category_unknown/total*100:.1f}%)",
|
|
||||||
f"- **No Description:** {no_description} ({no_description/total*100:.1f}%)",
|
|
||||||
f"- **Time Window Unknown:** {tw_unknown} ({tw_unknown/total*100:.1f}%)",
|
|
||||||
f"- **Activity without Quality Filter:** {no_quality_filter}",
|
|
||||||
f"- **Data Layer without Confidence Logic:** {no_confidence}",
|
|
||||||
f"- **Legacy/Implementation Mismatch:** {legacy_mismatch}",
|
|
||||||
f"- **Orphaned (unused):** {orphaned}",
|
|
||||||
"",
|
|
||||||
"## Completeness Distribution",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Completeness buckets
|
|
||||||
buckets = {
|
|
||||||
"90-100%": sum(1 for m in all_metadata.values() if m.metadata_completeness_score >= 90),
|
|
||||||
"70-89%": sum(1 for m in all_metadata.values() if 70 <= m.metadata_completeness_score < 90),
|
|
||||||
"50-69%": sum(1 for m in all_metadata.values() if 50 <= m.metadata_completeness_score < 70),
|
|
||||||
"0-49%": sum(1 for m in all_metadata.values() if m.metadata_completeness_score < 50),
|
|
||||||
}
|
|
||||||
|
|
||||||
for bucket, count in buckets.items():
|
|
||||||
lines.append(f"- **{bucket}:** {count} placeholders ({count/total*100:.1f}%)")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## Top 20 Most Problematic Placeholders")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("| Rank | Placeholder | Completeness | Unresolved | Issues |")
|
|
||||||
lines.append("|------|-------------|--------------|------------|--------|")
|
|
||||||
|
|
||||||
for i, (key, _, score, unresolved_count, issues_count) in enumerate(problematic[:20], 1):
|
|
||||||
lines.append(f"| {i} | `{{{{{key}}}}}` | {score}% | {unresolved_count} | {issues_count} |")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## Schema Status Distribution")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
status_counts = {}
|
|
||||||
for m in all_metadata.values():
|
|
||||||
status_counts[m.schema_status] = status_counts.get(m.schema_status, 0) + 1
|
|
||||||
|
|
||||||
for status, count in sorted(status_counts.items()):
|
|
||||||
lines.append(f"- **{status}:** {count} ({count/total*100:.1f}%)")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_unresolved_report(registry) -> dict:
|
|
||||||
"""
|
|
||||||
Generate unresolved fields report as JSON.
|
|
||||||
"""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
|
|
||||||
unresolved_by_placeholder = {}
|
|
||||||
unresolved_by_field = {}
|
|
||||||
|
|
||||||
for key, m in all_metadata.items():
|
|
||||||
if m.unresolved_fields:
|
|
||||||
unresolved_by_placeholder[key] = m.unresolved_fields
|
|
||||||
|
|
||||||
for field in m.unresolved_fields:
|
|
||||||
if field not in unresolved_by_field:
|
|
||||||
unresolved_by_field[field] = []
|
|
||||||
unresolved_by_field[field].append(key)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"generated_at": datetime.now().isoformat(),
|
|
||||||
"total_placeholders_with_unresolved": len(unresolved_by_placeholder),
|
|
||||||
"by_placeholder": unresolved_by_placeholder,
|
|
||||||
"by_field": unresolved_by_field,
|
|
||||||
"summary": {
|
|
||||||
field: len(placeholders)
|
|
||||||
for field, placeholders in unresolved_by_field.items()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main execution."""
|
|
||||||
print("="*60)
|
|
||||||
print("ENHANCED PLACEHOLDER METADATA GENERATION V2")
|
|
||||||
print("="*60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build registry
|
|
||||||
print("Building metadata registry...")
|
|
||||||
registry = build_complete_metadata_registry()
|
|
||||||
print(f"Loaded {registry.count()} placeholders")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Apply enhanced corrections
|
|
||||||
print("Applying enhanced corrections...")
|
|
||||||
registry = apply_enhanced_corrections(registry)
|
|
||||||
print("Enhanced corrections applied")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Generate reports
|
|
||||||
print("Generating QA report...")
|
|
||||||
qa_report = generate_qa_report(registry)
|
|
||||||
qa_path = Path(__file__).parent.parent / "docs" / "PLACEHOLDER_METADATA_QA_REPORT.md"
|
|
||||||
with open(qa_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(qa_report)
|
|
||||||
print(f"QA Report: {qa_path}")
|
|
||||||
|
|
||||||
print("Generating unresolved report...")
|
|
||||||
unresolved = generate_unresolved_report(registry)
|
|
||||||
unresolved_path = Path(__file__).parent.parent / "docs" / "PLACEHOLDER_METADATA_UNRESOLVED.json"
|
|
||||||
with open(unresolved_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(unresolved, f, indent=2, ensure_ascii=False)
|
|
||||||
print(f"Unresolved Report: {unresolved_path}")
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
avg_completeness = sum(m.metadata_completeness_score for m in all_metadata.values()) / len(all_metadata)
|
|
||||||
validated_count = sum(1 for m in all_metadata.values() if m.schema_status == "validated")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("="*60)
|
|
||||||
print("SUMMARY")
|
|
||||||
print("="*60)
|
|
||||||
print(f"Total Placeholders: {len(all_metadata)}")
|
|
||||||
print(f"Average Completeness: {avg_completeness:.1f}%")
|
|
||||||
print(f"Validated: {validated_count} ({validated_count/len(all_metadata)*100:.1f}%)")
|
|
||||||
print(f"Time Window Unknown: {sum(1 for m in all_metadata.values() if m.time_window == TimeWindow.UNKNOWN)}")
|
|
||||||
print(f"Orphaned: {sum(1 for m in all_metadata.values() if m.orphaned_placeholder)}")
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\nERROR: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
@ -1,530 +0,0 @@
|
||||||
"""
|
|
||||||
Placeholder Catalog Generator
|
|
||||||
|
|
||||||
Generates comprehensive documentation for all placeholders:
|
|
||||||
1. PLACEHOLDER_CATALOG_EXTENDED.json - Machine-readable full metadata
|
|
||||||
2. PLACEHOLDER_CATALOG_EXTENDED.md - Human-readable catalog
|
|
||||||
3. PLACEHOLDER_GAP_REPORT.md - Technical gaps and issues
|
|
||||||
4. PLACEHOLDER_EXPORT_SPEC.md - Export format specification
|
|
||||||
|
|
||||||
This implements the normative standard for placeholder documentation.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, List, Any
|
|
||||||
|
|
||||||
# Add backend to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
|
|
||||||
from placeholder_metadata import (
|
|
||||||
PlaceholderMetadata,
|
|
||||||
PlaceholderType,
|
|
||||||
TimeWindow,
|
|
||||||
OutputType,
|
|
||||||
METADATA_REGISTRY
|
|
||||||
)
|
|
||||||
from placeholder_metadata_extractor import build_complete_metadata_registry
|
|
||||||
from generate_complete_metadata import apply_manual_corrections, generate_gap_report
|
|
||||||
|
|
||||||
|
|
||||||
# ── 1. JSON Catalog ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def generate_json_catalog(registry, output_dir: Path):
|
|
||||||
"""Generate PLACEHOLDER_CATALOG_EXTENDED.json"""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
|
|
||||||
catalog = {
|
|
||||||
"schema_version": "1.0.0",
|
|
||||||
"generated_at": datetime.now().isoformat(),
|
|
||||||
"normative_standard": "PLACEHOLDER_METADATA_REQUIREMENTS_V2_NORMATIVE.md",
|
|
||||||
"total_placeholders": len(all_metadata),
|
|
||||||
"placeholders": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, metadata in sorted(all_metadata.items()):
|
|
||||||
catalog["placeholders"][key] = metadata.to_dict()
|
|
||||||
|
|
||||||
output_path = output_dir / "PLACEHOLDER_CATALOG_EXTENDED.json"
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(catalog, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
print(f"Generated: {output_path}")
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
# ── 2. Markdown Catalog ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def generate_markdown_catalog(registry, output_dir: Path):
|
|
||||||
"""Generate PLACEHOLDER_CATALOG_EXTENDED.md"""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
by_category = registry.get_by_category()
|
|
||||||
|
|
||||||
md = []
|
|
||||||
md.append("# Placeholder Catalog (Extended)")
|
|
||||||
md.append("")
|
|
||||||
md.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
md.append(f"**Total Placeholders:** {len(all_metadata)}")
|
|
||||||
md.append(f"**Normative Standard:** PLACEHOLDER_METADATA_REQUIREMENTS_V2_NORMATIVE.md")
|
|
||||||
md.append("")
|
|
||||||
md.append("---")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Summary Statistics
|
|
||||||
md.append("## Summary Statistics")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# By Type
|
|
||||||
by_type = {}
|
|
||||||
for metadata in all_metadata.values():
|
|
||||||
ptype = metadata.type.value
|
|
||||||
by_type[ptype] = by_type.get(ptype, 0) + 1
|
|
||||||
|
|
||||||
md.append("### By Type")
|
|
||||||
md.append("")
|
|
||||||
md.append("| Type | Count | Percentage |")
|
|
||||||
md.append("|------|-------|------------|")
|
|
||||||
for ptype, count in sorted(by_type.items()):
|
|
||||||
pct = count / len(all_metadata) * 100
|
|
||||||
md.append(f"| {ptype} | {count} | {pct:.1f}% |")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# By Category
|
|
||||||
md.append("### By Category")
|
|
||||||
md.append("")
|
|
||||||
md.append("| Category | Count |")
|
|
||||||
md.append("|----------|-------|")
|
|
||||||
for category, metadata_list in sorted(by_category.items()):
|
|
||||||
md.append(f"| {category} | {len(metadata_list)} |")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
md.append("---")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Detailed Catalog by Category
|
|
||||||
md.append("## Detailed Placeholder Catalog")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
for category, metadata_list in sorted(by_category.items()):
|
|
||||||
md.append(f"### {category} ({len(metadata_list)} placeholders)")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
for metadata in sorted(metadata_list, key=lambda m: m.key):
|
|
||||||
md.append(f"#### `{{{{{metadata.key}}}}}`")
|
|
||||||
md.append("")
|
|
||||||
md.append(f"**Description:** {metadata.description}")
|
|
||||||
md.append("")
|
|
||||||
md.append(f"**Semantic Contract:** {metadata.semantic_contract}")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Metadata table
|
|
||||||
md.append("| Property | Value |")
|
|
||||||
md.append("|----------|-------|")
|
|
||||||
md.append(f"| Type | `{metadata.type.value}` |")
|
|
||||||
md.append(f"| Time Window | `{metadata.time_window.value}` |")
|
|
||||||
md.append(f"| Output Type | `{metadata.output_type.value}` |")
|
|
||||||
md.append(f"| Unit | {metadata.unit or 'None'} |")
|
|
||||||
md.append(f"| Format Hint | {metadata.format_hint or 'None'} |")
|
|
||||||
md.append(f"| Version | {metadata.version} |")
|
|
||||||
md.append(f"| Deprecated | {metadata.deprecated} |")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Source
|
|
||||||
md.append("**Source:**")
|
|
||||||
md.append(f"- Resolver: `{metadata.source.resolver}`")
|
|
||||||
md.append(f"- Module: `{metadata.source.module}`")
|
|
||||||
if metadata.source.function:
|
|
||||||
md.append(f"- Function: `{metadata.source.function}`")
|
|
||||||
if metadata.source.data_layer_module:
|
|
||||||
md.append(f"- Data Layer: `{metadata.source.data_layer_module}`")
|
|
||||||
if metadata.source.source_tables:
|
|
||||||
tables = ", ".join([f"`{t}`" for t in metadata.source.source_tables])
|
|
||||||
md.append(f"- Tables: {tables}")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Known Issues
|
|
||||||
if metadata.known_issues:
|
|
||||||
md.append("**Known Issues:**")
|
|
||||||
for issue in metadata.known_issues:
|
|
||||||
md.append(f"- {issue}")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Notes
|
|
||||||
if metadata.notes:
|
|
||||||
md.append("**Notes:**")
|
|
||||||
for note in metadata.notes:
|
|
||||||
md.append(f"- {note}")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
md.append("---")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
output_path = output_dir / "PLACEHOLDER_CATALOG_EXTENDED.md"
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("\n".join(md))
|
|
||||||
|
|
||||||
print(f"Generated: {output_path}")
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
# ── 3. Gap Report ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def generate_gap_report_md(registry, gaps: Dict, output_dir: Path):
|
|
||||||
"""Generate PLACEHOLDER_GAP_REPORT.md"""
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
total = len(all_metadata)
|
|
||||||
|
|
||||||
md = []
|
|
||||||
md.append("# Placeholder Metadata Gap Report")
|
|
||||||
md.append("")
|
|
||||||
md.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
md.append(f"**Total Placeholders:** {total}")
|
|
||||||
md.append("")
|
|
||||||
md.append("This report identifies placeholders with incomplete or unresolved metadata fields.")
|
|
||||||
md.append("")
|
|
||||||
md.append("---")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
gap_count = sum(len(v) for v in gaps.values())
|
|
||||||
coverage = (1 - gap_count / (total * 6)) * 100 # 6 gap types
|
|
||||||
|
|
||||||
md.append("## Summary")
|
|
||||||
md.append("")
|
|
||||||
md.append(f"- **Total Gap Instances:** {gap_count}")
|
|
||||||
md.append(f"- **Metadata Coverage:** {coverage:.1f}%")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Detailed Gaps
|
|
||||||
md.append("## Detailed Gap Analysis")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
for gap_type, placeholders in sorted(gaps.items()):
|
|
||||||
if not placeholders:
|
|
||||||
continue
|
|
||||||
|
|
||||||
md.append(f"### {gap_type.replace('_', ' ').title()}")
|
|
||||||
md.append("")
|
|
||||||
md.append(f"**Count:** {len(placeholders)}")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Get category for each placeholder
|
|
||||||
by_cat = {}
|
|
||||||
for key in placeholders:
|
|
||||||
metadata = registry.get(key)
|
|
||||||
if metadata:
|
|
||||||
cat = metadata.category
|
|
||||||
if cat not in by_cat:
|
|
||||||
by_cat[cat] = []
|
|
||||||
by_cat[cat].append(key)
|
|
||||||
|
|
||||||
for category, keys in sorted(by_cat.items()):
|
|
||||||
md.append(f"#### {category}")
|
|
||||||
md.append("")
|
|
||||||
for key in sorted(keys):
|
|
||||||
md.append(f"- `{{{{{key}}}}}`")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Recommendations
|
|
||||||
md.append("---")
|
|
||||||
md.append("")
|
|
||||||
md.append("## Recommendations")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
if gaps.get('unknown_time_window'):
|
|
||||||
md.append("### Time Window Resolution")
|
|
||||||
md.append("")
|
|
||||||
md.append("Placeholders with unknown time windows should be analyzed to determine:")
|
|
||||||
md.append("- Whether they use `latest`, `7d`, `28d`, `30d`, `90d`, or `custom`")
|
|
||||||
md.append("- Document in semantic_contract if time window is variable")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
if gaps.get('legacy_unknown_type'):
|
|
||||||
md.append("### Type Classification")
|
|
||||||
md.append("")
|
|
||||||
md.append("Placeholders with `legacy_unknown` type should be classified as:")
|
|
||||||
md.append("- `atomic` - Single atomic value")
|
|
||||||
md.append("- `raw_data` - Structured raw data (JSON, lists)")
|
|
||||||
md.append("- `interpreted` - AI-interpreted or derived values")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
if gaps.get('missing_data_layer_module'):
|
|
||||||
md.append("### Data Layer Tracking")
|
|
||||||
md.append("")
|
|
||||||
md.append("Placeholders without data_layer_module should be investigated:")
|
|
||||||
md.append("- Check if they call data_layer functions")
|
|
||||||
md.append("- Document direct database access if no data_layer function exists")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
output_path = output_dir / "PLACEHOLDER_GAP_REPORT.md"
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("\n".join(md))
|
|
||||||
|
|
||||||
print(f"Generated: {output_path}")
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
# ── 4. Export Spec ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def generate_export_spec_md(output_dir: Path):
|
|
||||||
"""Generate PLACEHOLDER_EXPORT_SPEC.md"""
|
|
||||||
md = []
|
|
||||||
md.append("# Placeholder Export Specification")
|
|
||||||
md.append("")
|
|
||||||
md.append(f"**Version:** 1.0.0")
|
|
||||||
md.append(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
||||||
md.append(f"**Normative Standard:** PLACEHOLDER_METADATA_REQUIREMENTS_V2_NORMATIVE.md")
|
|
||||||
md.append("")
|
|
||||||
md.append("---")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Overview
|
|
||||||
md.append("## Overview")
|
|
||||||
md.append("")
|
|
||||||
md.append("The Placeholder Export API provides two endpoints:")
|
|
||||||
md.append("")
|
|
||||||
md.append("1. **Legacy Export** (`/api/prompts/placeholders/export-values`)")
|
|
||||||
md.append(" - Backward-compatible format")
|
|
||||||
md.append(" - Simple key-value pairs")
|
|
||||||
md.append(" - Organized by category")
|
|
||||||
md.append("")
|
|
||||||
md.append("2. **Extended Export** (`/api/prompts/placeholders/export-values-extended`)")
|
|
||||||
md.append(" - Complete normative metadata")
|
|
||||||
md.append(" - Runtime value resolution")
|
|
||||||
md.append(" - Gap analysis")
|
|
||||||
md.append(" - Validation results")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Extended Export Format
|
|
||||||
md.append("## Extended Export Format")
|
|
||||||
md.append("")
|
|
||||||
md.append("### Root Structure")
|
|
||||||
md.append("")
|
|
||||||
md.append("```json")
|
|
||||||
md.append("{")
|
|
||||||
md.append(' "schema_version": "1.0.0",')
|
|
||||||
md.append(' "export_date": "2026-03-29T12:00:00Z",')
|
|
||||||
md.append(' "profile_id": "user-123",')
|
|
||||||
md.append(' "legacy": { ... },')
|
|
||||||
md.append(' "metadata": { ... },')
|
|
||||||
md.append(' "validation": { ... }')
|
|
||||||
md.append("}")
|
|
||||||
md.append("```")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Legacy Section
|
|
||||||
md.append("### Legacy Section")
|
|
||||||
md.append("")
|
|
||||||
md.append("Maintains backward compatibility with existing export consumers.")
|
|
||||||
md.append("")
|
|
||||||
md.append("```json")
|
|
||||||
md.append('"legacy": {')
|
|
||||||
md.append(' "all_placeholders": {')
|
|
||||||
md.append(' "weight_aktuell": "85.8 kg",')
|
|
||||||
md.append(' "name": "Max Mustermann",')
|
|
||||||
md.append(' ...')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' "placeholders_by_category": {')
|
|
||||||
md.append(' "Körper": [')
|
|
||||||
md.append(' {')
|
|
||||||
md.append(' "key": "{{weight_aktuell}}",')
|
|
||||||
md.append(' "description": "Aktuelles Gewicht in kg",')
|
|
||||||
md.append(' "value": "85.8 kg",')
|
|
||||||
md.append(' "example": "85.8 kg"')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' ...')
|
|
||||||
md.append(' ],')
|
|
||||||
md.append(' ...')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' "count": 116')
|
|
||||||
md.append('}')
|
|
||||||
md.append("```")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Metadata Section
|
|
||||||
md.append("### Metadata Section")
|
|
||||||
md.append("")
|
|
||||||
md.append("Complete normative metadata for all placeholders.")
|
|
||||||
md.append("")
|
|
||||||
md.append("```json")
|
|
||||||
md.append('"metadata": {')
|
|
||||||
md.append(' "flat": [')
|
|
||||||
md.append(' {')
|
|
||||||
md.append(' "key": "weight_aktuell",')
|
|
||||||
md.append(' "placeholder": "{{weight_aktuell}}",')
|
|
||||||
md.append(' "category": "Körper",')
|
|
||||||
md.append(' "type": "atomic",')
|
|
||||||
md.append(' "description": "Aktuelles Gewicht in kg",')
|
|
||||||
md.append(' "semantic_contract": "Letzter verfügbarer Gewichtseintrag...",')
|
|
||||||
md.append(' "unit": "kg",')
|
|
||||||
md.append(' "time_window": "latest",')
|
|
||||||
md.append(' "output_type": "number",')
|
|
||||||
md.append(' "format_hint": "85.8 kg",')
|
|
||||||
md.append(' "value_display": "85.8 kg",')
|
|
||||||
md.append(' "value_raw": 85.8,')
|
|
||||||
md.append(' "available": true,')
|
|
||||||
md.append(' "source": {')
|
|
||||||
md.append(' "resolver": "get_latest_weight",')
|
|
||||||
md.append(' "module": "placeholder_resolver.py",')
|
|
||||||
md.append(' "function": "get_latest_weight_data",')
|
|
||||||
md.append(' "data_layer_module": "body_metrics",')
|
|
||||||
md.append(' "source_tables": ["weight_log"]')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' ...')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' ...')
|
|
||||||
md.append(' ],')
|
|
||||||
md.append(' "by_category": { ... },')
|
|
||||||
md.append(' "summary": {')
|
|
||||||
md.append(' "total_placeholders": 116,')
|
|
||||||
md.append(' "available": 98,')
|
|
||||||
md.append(' "missing": 18,')
|
|
||||||
md.append(' "by_type": {')
|
|
||||||
md.append(' "atomic": 85,')
|
|
||||||
md.append(' "interpreted": 20,')
|
|
||||||
md.append(' "raw_data": 8,')
|
|
||||||
md.append(' "legacy_unknown": 3')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' "coverage": {')
|
|
||||||
md.append(' "fully_resolved": 75,')
|
|
||||||
md.append(' "partially_resolved": 30,')
|
|
||||||
md.append(' "unresolved": 11')
|
|
||||||
md.append(' }')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' "gaps": {')
|
|
||||||
md.append(' "unknown_time_window": ["placeholder1", ...],')
|
|
||||||
md.append(' "missing_semantic_contract": [...],')
|
|
||||||
md.append(' ...')
|
|
||||||
md.append(' }')
|
|
||||||
md.append('}')
|
|
||||||
md.append("```")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Validation Section
|
|
||||||
md.append("### Validation Section")
|
|
||||||
md.append("")
|
|
||||||
md.append("Results of normative standard validation.")
|
|
||||||
md.append("")
|
|
||||||
md.append("```json")
|
|
||||||
md.append('"validation": {')
|
|
||||||
md.append(' "compliant": 89,')
|
|
||||||
md.append(' "non_compliant": 27,')
|
|
||||||
md.append(' "issues": [')
|
|
||||||
md.append(' {')
|
|
||||||
md.append(' "placeholder": "activity_summary",')
|
|
||||||
md.append(' "violations": [')
|
|
||||||
md.append(' {')
|
|
||||||
md.append(' "field": "time_window",')
|
|
||||||
md.append(' "issue": "Time window UNKNOWN should be resolved",')
|
|
||||||
md.append(' "severity": "warning"')
|
|
||||||
md.append(' }')
|
|
||||||
md.append(' ]')
|
|
||||||
md.append(' },')
|
|
||||||
md.append(' ...')
|
|
||||||
md.append(' ]')
|
|
||||||
md.append('}')
|
|
||||||
md.append("```")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Usage
|
|
||||||
md.append("## API Usage")
|
|
||||||
md.append("")
|
|
||||||
md.append("### Legacy Export")
|
|
||||||
md.append("")
|
|
||||||
md.append("```bash")
|
|
||||||
md.append("GET /api/prompts/placeholders/export-values")
|
|
||||||
md.append("Header: X-Auth-Token: <token>")
|
|
||||||
md.append("```")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
md.append("### Extended Export")
|
|
||||||
md.append("")
|
|
||||||
md.append("```bash")
|
|
||||||
md.append("GET /api/prompts/placeholders/export-values-extended")
|
|
||||||
md.append("Header: X-Auth-Token: <token>")
|
|
||||||
md.append("```")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
# Standards Compliance
|
|
||||||
md.append("## Standards Compliance")
|
|
||||||
md.append("")
|
|
||||||
md.append("The extended export implements the following normative requirements:")
|
|
||||||
md.append("")
|
|
||||||
md.append("1. **Non-Breaking:** Legacy export remains unchanged")
|
|
||||||
md.append("2. **Complete Metadata:** All fields from normative standard")
|
|
||||||
md.append("3. **Runtime Resolution:** Values resolved for current profile")
|
|
||||||
md.append("4. **Gap Transparency:** Unresolved fields explicitly marked")
|
|
||||||
md.append("5. **Validation:** Automated compliance checking")
|
|
||||||
md.append("6. **Versioning:** Schema version for future evolution")
|
|
||||||
md.append("")
|
|
||||||
|
|
||||||
output_path = output_dir / "PLACEHOLDER_EXPORT_SPEC.md"
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("\n".join(md))
|
|
||||||
|
|
||||||
print(f"Generated: {output_path}")
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main catalog generation function."""
|
|
||||||
print("="*60)
|
|
||||||
print("PLACEHOLDER CATALOG GENERATOR")
|
|
||||||
print("="*60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Setup output directory
|
|
||||||
output_dir = Path(__file__).parent.parent / "docs"
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
print(f"Output directory: {output_dir}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build registry
|
|
||||||
print("Building metadata registry...")
|
|
||||||
registry = build_complete_metadata_registry()
|
|
||||||
registry = apply_manual_corrections(registry)
|
|
||||||
print(f"Loaded {registry.count()} placeholders")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Generate gap report data
|
|
||||||
print("Analyzing gaps...")
|
|
||||||
gaps = generate_gap_report(registry)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Generate all documentation files
|
|
||||||
print("Generating documentation files...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
generate_json_catalog(registry, output_dir)
|
|
||||||
generate_markdown_catalog(registry, output_dir)
|
|
||||||
generate_gap_report_md(registry, gaps, output_dir)
|
|
||||||
generate_export_spec_md(output_dir)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("="*60)
|
|
||||||
print("CATALOG GENERATION COMPLETE")
|
|
||||||
print("="*60)
|
|
||||||
print()
|
|
||||||
print("Generated files:")
|
|
||||||
print(f" 1. {output_dir}/PLACEHOLDER_CATALOG_EXTENDED.json")
|
|
||||||
print(f" 2. {output_dir}/PLACEHOLDER_CATALOG_EXTENDED.md")
|
|
||||||
print(f" 3. {output_dir}/PLACEHOLDER_GAP_REPORT.md")
|
|
||||||
print(f" 4. {output_dir}/PLACEHOLDER_EXPORT_SPEC.md")
|
|
||||||
print()
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print()
|
|
||||||
print(f"ERROR: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
@ -1,568 +0,0 @@
|
||||||
"""
|
|
||||||
Goal Utilities - Abstraction Layer for Focus Weights & Universal Value Fetcher
|
|
||||||
|
|
||||||
This module provides:
|
|
||||||
1. Abstraction layer between goal modes and focus weights (Phase 1)
|
|
||||||
2. Universal value fetcher for dynamic goal types (Phase 1.5)
|
|
||||||
|
|
||||||
Version History:
|
|
||||||
- V1 (Phase 1): Maps goal_mode to predefined weights
|
|
||||||
- V1.5 (Phase 1.5): Universal value fetcher for DB-registry goal types
|
|
||||||
- V2 (future): Reads from focus_areas table with custom user weights
|
|
||||||
|
|
||||||
Part of Phase 1 + Phase 1.5: Flexible Goal System
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, Optional, Any, List
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from decimal import Decimal
|
|
||||||
import json
|
|
||||||
from db import get_cursor, get_db
|
|
||||||
|
|
||||||
|
|
||||||
def get_focus_weights(conn, profile_id: str) -> Dict[str, float]:
|
|
||||||
"""
|
|
||||||
Get focus area weights for a profile.
|
|
||||||
|
|
||||||
V2 (Goal System v2.0): Reads from focus_areas table with custom user weights.
|
|
||||||
Falls back to goal_mode mapping if focus_areas not set.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Database connection
|
|
||||||
profile_id: User's profile ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with focus weights (sum = 1.0):
|
|
||||||
{
|
|
||||||
'weight_loss': 0.3, # Fat loss priority
|
|
||||||
'muscle_gain': 0.2, # Muscle gain priority
|
|
||||||
'strength': 0.25, # Strength training priority
|
|
||||||
'endurance': 0.25, # Cardio/endurance priority
|
|
||||||
'flexibility': 0.0, # Mobility priority
|
|
||||||
'health': 0.0 # General health maintenance
|
|
||||||
}
|
|
||||||
|
|
||||||
Example Usage in Phase 0b:
|
|
||||||
weights = get_focus_weights(conn, profile_id)
|
|
||||||
|
|
||||||
# Score calculation considers user's focus
|
|
||||||
overall_score = (
|
|
||||||
body_score * weights['weight_loss'] +
|
|
||||||
strength_score * weights['strength'] +
|
|
||||||
cardio_score * weights['endurance']
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# V2: Try to fetch from focus_areas table
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
|
|
||||||
endurance_pct, flexibility_pct, health_pct
|
|
||||||
FROM focus_areas
|
|
||||||
WHERE profile_id = %s AND active = true
|
|
||||||
LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
# Convert percentages to weights (0-1 range)
|
|
||||||
return {
|
|
||||||
'weight_loss': row['weight_loss_pct'] / 100.0,
|
|
||||||
'muscle_gain': row['muscle_gain_pct'] / 100.0,
|
|
||||||
'strength': row['strength_pct'] / 100.0,
|
|
||||||
'endurance': row['endurance_pct'] / 100.0,
|
|
||||||
'flexibility': row['flexibility_pct'] / 100.0,
|
|
||||||
'health': row['health_pct'] / 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# V1 Fallback: Use goal_mode if focus_areas not set
|
|
||||||
cur.execute(
|
|
||||||
"SELECT goal_mode FROM profiles WHERE id = %s",
|
|
||||||
(profile_id,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
# Ultimate fallback: balanced health focus
|
|
||||||
return {
|
|
||||||
'weight_loss': 0.0,
|
|
||||||
'muscle_gain': 0.0,
|
|
||||||
'strength': 0.10,
|
|
||||||
'endurance': 0.20,
|
|
||||||
'flexibility': 0.15,
|
|
||||||
'health': 0.55
|
|
||||||
}
|
|
||||||
|
|
||||||
goal_mode = row['goal_mode']
|
|
||||||
|
|
||||||
if not goal_mode:
|
|
||||||
return {
|
|
||||||
'weight_loss': 0.0,
|
|
||||||
'muscle_gain': 0.0,
|
|
||||||
'strength': 0.10,
|
|
||||||
'endurance': 0.20,
|
|
||||||
'flexibility': 0.15,
|
|
||||||
'health': 0.55
|
|
||||||
}
|
|
||||||
|
|
||||||
# V1: Predefined weight mappings per goal_mode (fallback)
|
|
||||||
WEIGHT_MAPPINGS = {
|
|
||||||
'weight_loss': {
|
|
||||||
'weight_loss': 0.60,
|
|
||||||
'endurance': 0.20,
|
|
||||||
'muscle_gain': 0.0,
|
|
||||||
'strength': 0.10,
|
|
||||||
'flexibility': 0.05,
|
|
||||||
'health': 0.05
|
|
||||||
},
|
|
||||||
'strength': {
|
|
||||||
'strength': 0.50,
|
|
||||||
'muscle_gain': 0.40,
|
|
||||||
'endurance': 0.10,
|
|
||||||
'weight_loss': 0.0,
|
|
||||||
'flexibility': 0.0,
|
|
||||||
'health': 0.0
|
|
||||||
},
|
|
||||||
'endurance': {
|
|
||||||
'endurance': 0.70,
|
|
||||||
'health': 0.20,
|
|
||||||
'flexibility': 0.10,
|
|
||||||
'weight_loss': 0.0,
|
|
||||||
'muscle_gain': 0.0,
|
|
||||||
'strength': 0.0
|
|
||||||
},
|
|
||||||
'recomposition': {
|
|
||||||
'weight_loss': 0.30,
|
|
||||||
'muscle_gain': 0.30,
|
|
||||||
'strength': 0.25,
|
|
||||||
'endurance': 0.10,
|
|
||||||
'flexibility': 0.05,
|
|
||||||
'health': 0.0
|
|
||||||
},
|
|
||||||
'health': {
|
|
||||||
'health': 0.50,
|
|
||||||
'endurance': 0.20,
|
|
||||||
'flexibility': 0.15,
|
|
||||||
'strength': 0.10,
|
|
||||||
'weight_loss': 0.05,
|
|
||||||
'muscle_gain': 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return WEIGHT_MAPPINGS.get(goal_mode, WEIGHT_MAPPINGS['health'])
|
|
||||||
|
|
||||||
|
|
||||||
def get_primary_focus(conn, profile_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get the primary focus area for a profile.
|
|
||||||
|
|
||||||
Returns the focus area with the highest weight.
|
|
||||||
Useful for UI labels and simple decision logic.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Database connection
|
|
||||||
profile_id: User's profile ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Primary focus area name (e.g., 'weight_loss', 'strength')
|
|
||||||
"""
|
|
||||||
weights = get_focus_weights(conn, profile_id)
|
|
||||||
return max(weights.items(), key=lambda x: x[1])[0]
|
|
||||||
|
|
||||||
|
|
||||||
def get_focus_description(focus_area: str) -> str:
|
|
||||||
"""
|
|
||||||
Get human-readable description for a focus area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
focus_area: Focus area key (e.g., 'weight_loss')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
German description for UI display
|
|
||||||
"""
|
|
||||||
descriptions = {
|
|
||||||
'weight_loss': 'Gewichtsreduktion & Fettabbau',
|
|
||||||
'muscle_gain': 'Muskelaufbau & Hypertrophie',
|
|
||||||
'strength': 'Kraftsteigerung & Performance',
|
|
||||||
'endurance': 'Ausdauer & aerobe Kapazität',
|
|
||||||
'flexibility': 'Beweglichkeit & Mobilität',
|
|
||||||
'health': 'Allgemeine Gesundheit & Erhaltung'
|
|
||||||
}
|
|
||||||
return descriptions.get(focus_area, focus_area)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Phase 1.5: Universal Value Fetcher for Dynamic Goal Types
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def get_goal_type_config(conn, type_key: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get goal type configuration from database registry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Database connection
|
|
||||||
type_key: Goal type key (e.g., 'weight', 'meditation_minutes')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with config or None if not found/inactive
|
|
||||||
"""
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT type_key, source_table, source_column, aggregation_method,
|
|
||||||
calculation_formula, filter_conditions, label_de, unit, icon, category
|
|
||||||
FROM goal_type_definitions
|
|
||||||
WHERE type_key = %s AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
""", (type_key,))
|
|
||||||
|
|
||||||
return cur.fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_value_for_goal(conn, profile_id: str, goal_type: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Universal value fetcher for any goal type.
|
|
||||||
|
|
||||||
Reads configuration from goal_type_definitions table and executes
|
|
||||||
appropriate query based on aggregation_method or calculation_formula.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Database connection
|
|
||||||
profile_id: User's profile ID
|
|
||||||
goal_type: Goal type key (e.g., 'weight', 'meditation_minutes')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Current value as float or None if not available
|
|
||||||
"""
|
|
||||||
config = get_goal_type_config(conn, goal_type)
|
|
||||||
|
|
||||||
if not config:
|
|
||||||
print(f"[WARNING] Goal type '{goal_type}' not found or inactive")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Complex calculation (e.g., lean_mass)
|
|
||||||
if config['calculation_formula']:
|
|
||||||
return _execute_calculation_formula(conn, profile_id, config['calculation_formula'])
|
|
||||||
|
|
||||||
# Simple aggregation
|
|
||||||
return _fetch_by_aggregation_method(
|
|
||||||
conn,
|
|
||||||
profile_id,
|
|
||||||
config['source_table'],
|
|
||||||
config['source_column'],
|
|
||||||
config['aggregation_method'],
|
|
||||||
config.get('filter_conditions')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_by_aggregation_method(
|
|
||||||
conn,
|
|
||||||
profile_id: str,
|
|
||||||
table: str,
|
|
||||||
column: str,
|
|
||||||
method: str,
|
|
||||||
filter_conditions: Optional[Any] = None
|
|
||||||
) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Fetch value using specified aggregation method.
|
|
||||||
|
|
||||||
Supported methods:
|
|
||||||
- latest: Most recent value
|
|
||||||
- avg_7d: 7-day average
|
|
||||||
- avg_30d: 30-day average
|
|
||||||
- sum_30d: 30-day sum
|
|
||||||
- count_7d: Count of entries in last 7 days
|
|
||||||
- count_30d: Count of entries in last 30 days
|
|
||||||
- min_30d: Minimum value in last 30 days
|
|
||||||
- max_30d: Maximum value in last 30 days
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filter_conditions: Optional JSON filters (e.g., {"training_category": "strength"})
|
|
||||||
"""
|
|
||||||
# Guard: source_table/column required for simple aggregation
|
|
||||||
if not table or not column:
|
|
||||||
print(f"[WARNING] Missing source_table or source_column for aggregation")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Table-specific date column mapping (some tables use different column names)
|
|
||||||
DATE_COLUMN_MAP = {
|
|
||||||
'blood_pressure_log': 'measured_at',
|
|
||||||
'activity_log': 'date',
|
|
||||||
'weight_log': 'date',
|
|
||||||
'circumference_log': 'date',
|
|
||||||
'caliper_log': 'date',
|
|
||||||
'nutrition_log': 'date',
|
|
||||||
'sleep_log': 'date',
|
|
||||||
'vitals_baseline': 'date',
|
|
||||||
'rest_days': 'date',
|
|
||||||
'fitness_tests': 'test_date'
|
|
||||||
}
|
|
||||||
date_col = DATE_COLUMN_MAP.get(table, 'date')
|
|
||||||
|
|
||||||
# Build filter SQL from JSON conditions
|
|
||||||
filter_sql = ""
|
|
||||||
filter_params = []
|
|
||||||
|
|
||||||
if filter_conditions:
|
|
||||||
try:
|
|
||||||
if isinstance(filter_conditions, str):
|
|
||||||
filters = json.loads(filter_conditions)
|
|
||||||
else:
|
|
||||||
filters = filter_conditions
|
|
||||||
|
|
||||||
for filter_col, filter_val in filters.items():
|
|
||||||
if isinstance(filter_val, list):
|
|
||||||
# IN clause for multiple values
|
|
||||||
placeholders = ', '.join(['%s'] * len(filter_val))
|
|
||||||
filter_sql += f" AND {filter_col} IN ({placeholders})"
|
|
||||||
filter_params.extend(filter_val)
|
|
||||||
else:
|
|
||||||
# Single value equality
|
|
||||||
filter_sql += f" AND {filter_col} = %s"
|
|
||||||
filter_params.append(filter_val)
|
|
||||||
except (json.JSONDecodeError, TypeError, AttributeError) as e:
|
|
||||||
print(f"[WARNING] Invalid filter_conditions: {e}, ignoring filters")
|
|
||||||
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if method == 'latest':
|
|
||||||
params = [profile_id] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT {column} FROM {table}
|
|
||||||
WHERE profile_id = %s AND {column} IS NOT NULL{filter_sql}
|
|
||||||
ORDER BY {date_col} DESC LIMIT 1
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row[column]) if row else None
|
|
||||||
|
|
||||||
elif method == 'avg_7d':
|
|
||||||
days_ago = date.today() - timedelta(days=7)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT AVG({column}) as avg_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row['avg_value']) if row and row['avg_value'] is not None else None
|
|
||||||
|
|
||||||
elif method == 'avg_30d':
|
|
||||||
days_ago = date.today() - timedelta(days=30)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT AVG({column}) as avg_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row['avg_value']) if row and row['avg_value'] is not None else None
|
|
||||||
|
|
||||||
elif method == 'sum_30d':
|
|
||||||
days_ago = date.today() - timedelta(days=30)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT SUM({column}) as sum_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row['sum_value']) if row and row['sum_value'] is not None else None
|
|
||||||
|
|
||||||
elif method == 'count_7d':
|
|
||||||
days_ago = date.today() - timedelta(days=7)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT COUNT(*) as count_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row['count_value']) if row else 0.0
|
|
||||||
|
|
||||||
elif method == 'count_30d':
|
|
||||||
days_ago = date.today() - timedelta(days=30)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT COUNT(*) as count_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row['count_value']) if row else 0.0
|
|
||||||
|
|
||||||
elif method == 'min_30d':
|
|
||||||
days_ago = date.today() - timedelta(days=30)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT MIN({column}) as min_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row['min_value']) if row and row['min_value'] is not None else None
|
|
||||||
|
|
||||||
elif method == 'max_30d':
|
|
||||||
days_ago = date.today() - timedelta(days=30)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT MAX({column}) as max_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s AND {column} IS NOT NULL{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return float(row['max_value']) if row and row['max_value'] is not None else None
|
|
||||||
|
|
||||||
elif method == 'avg_per_week_30d':
|
|
||||||
# Average count per week over 30 days
|
|
||||||
# Use case: Training frequency per week (smoothed over 4.3 weeks)
|
|
||||||
days_ago = date.today() - timedelta(days=30)
|
|
||||||
params = [profile_id, days_ago] + filter_params
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT COUNT(*) as count_value FROM {table}
|
|
||||||
WHERE profile_id = %s AND {date_col} >= %s{filter_sql}
|
|
||||||
""", params)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row and row['count_value'] is not None:
|
|
||||||
# 30 days = 4.285 weeks (30/7)
|
|
||||||
return round(float(row['count_value']) / 4.285, 2)
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"[WARNING] Unknown aggregation method: {method}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Log detailed error for debugging
|
|
||||||
print(f"[ERROR] Failed to fetch value from {table}.{column} using {method}: {e}")
|
|
||||||
print(f"[ERROR] Filter conditions: {filter_conditions}")
|
|
||||||
print(f"[ERROR] Filter SQL: {filter_sql}")
|
|
||||||
print(f"[ERROR] Filter params: {filter_params}")
|
|
||||||
|
|
||||||
# CRITICAL: Rollback transaction to avoid InFailedSqlTransaction errors
|
|
||||||
try:
|
|
||||||
conn.rollback()
|
|
||||||
print(f"[INFO] Transaction rolled back after query error")
|
|
||||||
except Exception as rollback_err:
|
|
||||||
print(f"[WARNING] Rollback failed: {rollback_err}")
|
|
||||||
|
|
||||||
# Return None so goal creation can continue without current_value
|
|
||||||
# (current_value will be NULL in the goal record)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _execute_calculation_formula(conn, profile_id: str, formula_json: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Execute complex calculation formula.
|
|
||||||
|
|
||||||
Currently supports:
|
|
||||||
- lean_mass: weight - (weight * body_fat_pct / 100)
|
|
||||||
|
|
||||||
Future: Parse JSON formula and execute dynamically.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Database connection
|
|
||||||
profile_id: User's profile ID
|
|
||||||
formula_json: JSON string with calculation config
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Calculated value or None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
formula = json.loads(formula_json)
|
|
||||||
calc_type = formula.get('type')
|
|
||||||
|
|
||||||
if calc_type == 'lean_mass':
|
|
||||||
# Get dependencies
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
weight_row = cur.fetchone()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT body_fat_pct FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC LIMIT 1
|
|
||||||
""", (profile_id,))
|
|
||||||
bf_row = cur.fetchone()
|
|
||||||
|
|
||||||
if weight_row and bf_row:
|
|
||||||
weight = float(weight_row['weight'])
|
|
||||||
bf_pct = float(bf_row['body_fat_pct'])
|
|
||||||
lean_mass = weight - (weight * bf_pct / 100.0)
|
|
||||||
return round(lean_mass, 2)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"[WARNING] Unknown calculation type: {calc_type}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except (json.JSONDecodeError, KeyError, ValueError, TypeError) as e:
|
|
||||||
print(f"[ERROR] Formula execution failed: {e}, formula={formula_json}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Future V2 Implementation (commented out for reference):
|
|
||||||
"""
|
|
||||||
def get_focus_weights_v2(conn, profile_id: str) -> Dict[str, float]:
|
|
||||||
'''V2: Read from focus_areas table with custom user weights'''
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute('''
|
|
||||||
SELECT weight_loss_pct, muscle_gain_pct, endurance_pct,
|
|
||||||
strength_pct, flexibility_pct, health_pct
|
|
||||||
FROM focus_areas
|
|
||||||
WHERE profile_id = %s AND active = true
|
|
||||||
LIMIT 1
|
|
||||||
''', (profile_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
# Fallback to V1 behavior
|
|
||||||
return get_focus_weights(conn, profile_id)
|
|
||||||
|
|
||||||
# Convert percentages to weights (0-1 range)
|
|
||||||
return {
|
|
||||||
'weight_loss': row['weight_loss_pct'] / 100.0,
|
|
||||||
'muscle_gain': row['muscle_gain_pct'] / 100.0,
|
|
||||||
'endurance': row['endurance_pct'] / 100.0,
|
|
||||||
'strength': row['strength_pct'] / 100.0,
|
|
||||||
'flexibility': row['flexibility_pct'] / 100.0,
|
|
||||||
'health': row['health_pct'] / 100.0
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_goals(profile_id: str) -> List[Dict]:
|
|
||||||
"""
|
|
||||||
Get all active goals for a profile.
|
|
||||||
Returns list of goal dicts with id, type, target_value, current_value, etc.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, goal_type, name, target_value, target_date,
|
|
||||||
current_value, start_value, start_date, progress_pct,
|
|
||||||
status, is_primary, created_at
|
|
||||||
FROM goals
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND status IN ('active', 'in_progress')
|
|
||||||
ORDER BY is_primary DESC, created_at DESC
|
|
||||||
""", (profile_id,))
|
|
||||||
|
|
||||||
return [dict(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
def get_goal_by_id(goal_id: str) -> Optional[Dict]:
|
|
||||||
"""Get a single goal by ID"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, profile_id, goal_type, target_value, target_date,
|
|
||||||
current_value, start_value, progress_pct, status, is_primary
|
|
||||||
FROM goals
|
|
||||||
WHERE id = %s
|
|
||||||
""", (goal_id,))
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
1917
backend/main.py
1917
backend/main.py
File diff suppressed because it is too large
Load Diff
1878
backend/main_old.py
1878
backend/main_old.py
File diff suppressed because it is too large
Load Diff
|
|
@ -1,25 +0,0 @@
|
||||||
-- ================================================================
|
|
||||||
-- Migration 003: Add Email Verification Fields
|
|
||||||
-- Version: v9c
|
|
||||||
-- Date: 2026-03-21
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
-- Add email verification columns to profiles table
|
|
||||||
ALTER TABLE profiles
|
|
||||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE,
|
|
||||||
ADD COLUMN IF NOT EXISTS verification_token TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS verification_expires TIMESTAMP WITH TIME ZONE;
|
|
||||||
|
|
||||||
-- Create index for verification token lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_verification_token
|
|
||||||
ON profiles(verification_token)
|
|
||||||
WHERE verification_token IS NOT NULL;
|
|
||||||
|
|
||||||
-- Mark existing users with email as verified (grandfather clause)
|
|
||||||
UPDATE profiles
|
|
||||||
SET email_verified = TRUE
|
|
||||||
WHERE email IS NOT NULL AND email_verified IS NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN profiles.email_verified IS 'Whether email address has been verified';
|
|
||||||
COMMENT ON COLUMN profiles.verification_token IS 'One-time token for email verification';
|
|
||||||
COMMENT ON COLUMN profiles.verification_expires IS 'Verification token expiry (24h from creation)';
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
-- Migration 004: Training Types & Categories
|
|
||||||
-- Part of v9d: Schlaf + Sport-Vertiefung
|
|
||||||
-- Created: 2026-03-21
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 1. Create training_types table
|
|
||||||
-- ========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS training_types (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
category VARCHAR(50) NOT NULL, -- Main category: 'cardio', 'strength', 'hiit', etc.
|
|
||||||
subcategory VARCHAR(50), -- Optional: 'running', 'hypertrophy', etc.
|
|
||||||
name_de VARCHAR(100) NOT NULL, -- German display name
|
|
||||||
name_en VARCHAR(100) NOT NULL, -- English display name
|
|
||||||
icon VARCHAR(10), -- Emoji icon
|
|
||||||
sort_order INTEGER DEFAULT 0, -- For UI ordering
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 2. Add training type columns to activity_log
|
|
||||||
-- ========================================
|
|
||||||
ALTER TABLE activity_log
|
|
||||||
ADD COLUMN IF NOT EXISTS training_type_id INTEGER REFERENCES training_types(id),
|
|
||||||
ADD COLUMN IF NOT EXISTS training_category VARCHAR(50), -- Denormalized for fast queries
|
|
||||||
ADD COLUMN IF NOT EXISTS training_subcategory VARCHAR(50); -- Denormalized
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 3. Create indexes
|
|
||||||
-- ========================================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_training_type ON activity_log(training_type_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_training_category ON activity_log(training_category);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_training_types_category ON training_types(category);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 4. Seed training types data
|
|
||||||
-- ========================================
|
|
||||||
|
|
||||||
-- Cardio (Ausdauer)
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('cardio', 'running', 'Laufen', 'Running', '🏃', 100),
|
|
||||||
('cardio', 'cycling', 'Radfahren', 'Cycling', '🚴', 101),
|
|
||||||
('cardio', 'swimming', 'Schwimmen', 'Swimming', '🏊', 102),
|
|
||||||
('cardio', 'rowing', 'Rudern', 'Rowing', '🚣', 103),
|
|
||||||
('cardio', 'other', 'Sonstiges Cardio', 'Other Cardio', '❤️', 104);
|
|
||||||
|
|
||||||
-- Kraft
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('strength', 'hypertrophy', 'Hypertrophie', 'Hypertrophy', '💪', 200),
|
|
||||||
('strength', 'maxstrength', 'Maximalkraft', 'Max Strength', '🏋️', 201),
|
|
||||||
('strength', 'endurance', 'Kraftausdauer', 'Strength Endurance', '🔁', 202),
|
|
||||||
('strength', 'functional', 'Funktionell', 'Functional', '⚡', 203);
|
|
||||||
|
|
||||||
-- Schnellkraft / HIIT
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('hiit', 'hiit', 'HIIT', 'HIIT', '🔥', 300),
|
|
||||||
('hiit', 'explosive', 'Explosiv', 'Explosive', '💥', 301),
|
|
||||||
('hiit', 'circuit', 'Circuit Training', 'Circuit Training', '🔄', 302);
|
|
||||||
|
|
||||||
-- Kampfsport / Technikkraft
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('martial_arts', 'technique', 'Techniktraining', 'Technique Training', '🥋', 400),
|
|
||||||
('martial_arts', 'sparring', 'Sparring / Wettkampf', 'Sparring / Competition', '🥊', 401),
|
|
||||||
('martial_arts', 'strength', 'Kraft für Kampfsport', 'Martial Arts Strength', '⚔️', 402);
|
|
||||||
|
|
||||||
-- Mobility & Dehnung
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('mobility', 'static', 'Statisches Dehnen', 'Static Stretching', '🧘', 500),
|
|
||||||
('mobility', 'dynamic', 'Dynamisches Dehnen', 'Dynamic Stretching', '🤸', 501),
|
|
||||||
('mobility', 'yoga', 'Yoga', 'Yoga', '🕉️', 502),
|
|
||||||
('mobility', 'fascia', 'Faszienarbeit', 'Fascia Work', '🎯', 503);
|
|
||||||
|
|
||||||
-- Erholung (aktiv)
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('recovery', 'walk', 'Spaziergang', 'Walk', '🚶', 600),
|
|
||||||
('recovery', 'swim_light', 'Leichtes Schwimmen', 'Light Swimming', '🏊', 601),
|
|
||||||
('recovery', 'regeneration', 'Regenerationseinheit', 'Regeneration', '💆', 602);
|
|
||||||
|
|
||||||
-- General / Uncategorized
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('other', NULL, 'Sonstiges', 'Other', '📝', 900);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 5. Add comment
|
|
||||||
-- ========================================
|
|
||||||
COMMENT ON TABLE training_types IS 'v9d: Training type categories and subcategories';
|
|
||||||
COMMENT ON TABLE activity_log IS 'Extended in v9d with training_type_id for categorization';
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- Migration 005: Extended Training Types
|
|
||||||
-- Add: Cardio (Gehen, Tanzen), Mind & Meditation category
|
|
||||||
-- Created: 2026-03-21
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- Add new cardio subcategories
|
|
||||||
-- ========================================
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('cardio', 'walk', 'Gehen', 'Walking', '🚶', 105),
|
|
||||||
('cardio', 'dance', 'Tanzen', 'Dance', '💃', 106);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- Add new category: Geist & Meditation
|
|
||||||
-- ========================================
|
|
||||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
|
||||||
('mind', 'meditation', 'Meditation', 'Meditation', '🧘♂️', 700),
|
|
||||||
('mind', 'breathwork', 'Atemarbeit', 'Breathwork', '🫁', 701),
|
|
||||||
('mind', 'mindfulness', 'Achtsamkeit', 'Mindfulness', '☮️', 702),
|
|
||||||
('mind', 'visualization', 'Visualisierung', 'Visualization', '🎨', 703);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- Add comment
|
|
||||||
-- ========================================
|
|
||||||
COMMENT ON TABLE training_types IS 'v9d Phase 1b: Extended with cardio walk/dance and mind category';
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
-- Migration 006: Training Types - Abilities Mapping
|
|
||||||
-- Add abilities JSONB column for future AI analysis
|
|
||||||
-- Maps to: koordinativ, konditionell, kognitiv, psychisch, taktisch
|
|
||||||
-- Created: 2026-03-21
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- Add abilities column
|
|
||||||
-- ========================================
|
|
||||||
ALTER TABLE training_types
|
|
||||||
ADD COLUMN IF NOT EXISTS abilities JSONB DEFAULT '{}';
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- Add description columns for better documentation
|
|
||||||
-- ========================================
|
|
||||||
ALTER TABLE training_types
|
|
||||||
ADD COLUMN IF NOT EXISTS description_de TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS description_en TEXT;
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- Add index for abilities queries
|
|
||||||
-- ========================================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_training_types_abilities ON training_types USING GIN (abilities);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- Comment
|
|
||||||
-- ========================================
|
|
||||||
COMMENT ON COLUMN training_types.abilities IS 'JSONB: Maps to athletic abilities for AI analysis (koordinativ, konditionell, kognitiv, psychisch, taktisch)';
|
|
||||||
COMMENT ON COLUMN training_types.description_de IS 'German description for admin UI and AI context';
|
|
||||||
COMMENT ON COLUMN training_types.description_en IS 'English description for admin UI and AI context';
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
-- Migration 007: Activity Type Mappings (Learnable System)
|
|
||||||
-- Replaces hardcoded mappings with DB-based configurable system
|
|
||||||
-- Created: 2026-03-21
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 1. Create activity_type_mappings table
|
|
||||||
-- ========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS activity_type_mappings (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
activity_type VARCHAR(100) NOT NULL,
|
|
||||||
training_type_id INTEGER NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
|
|
||||||
profile_id VARCHAR(36), -- NULL = global mapping, otherwise user-specific
|
|
||||||
source VARCHAR(20) DEFAULT 'manual', -- 'manual', 'bulk', 'admin', 'default'
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT unique_activity_type_per_profile UNIQUE(activity_type, profile_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 2. Create indexes
|
|
||||||
-- ========================================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_type ON activity_type_mappings(activity_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_profile ON activity_type_mappings(profile_id);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 3. Seed default mappings (global)
|
|
||||||
-- ========================================
|
|
||||||
-- Note: These are the German Apple Health workout types
|
|
||||||
-- training_type_id references are based on existing training_types data
|
|
||||||
|
|
||||||
-- Helper function to get training_type_id by subcategory
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
v_running_id INTEGER;
|
|
||||||
v_walk_id INTEGER;
|
|
||||||
v_cycling_id INTEGER;
|
|
||||||
v_swimming_id INTEGER;
|
|
||||||
v_hypertrophy_id INTEGER;
|
|
||||||
v_functional_id INTEGER;
|
|
||||||
v_hiit_id INTEGER;
|
|
||||||
v_yoga_id INTEGER;
|
|
||||||
v_technique_id INTEGER;
|
|
||||||
v_sparring_id INTEGER;
|
|
||||||
v_rowing_id INTEGER;
|
|
||||||
v_dance_id INTEGER;
|
|
||||||
v_static_id INTEGER;
|
|
||||||
v_regeneration_id INTEGER;
|
|
||||||
v_meditation_id INTEGER;
|
|
||||||
v_mindfulness_id INTEGER;
|
|
||||||
BEGIN
|
|
||||||
-- Get training_type IDs
|
|
||||||
SELECT id INTO v_running_id FROM training_types WHERE subcategory = 'running' LIMIT 1;
|
|
||||||
SELECT id INTO v_walk_id FROM training_types WHERE subcategory = 'walk' LIMIT 1;
|
|
||||||
SELECT id INTO v_cycling_id FROM training_types WHERE subcategory = 'cycling' LIMIT 1;
|
|
||||||
SELECT id INTO v_swimming_id FROM training_types WHERE subcategory = 'swimming' LIMIT 1;
|
|
||||||
SELECT id INTO v_hypertrophy_id FROM training_types WHERE subcategory = 'hypertrophy' LIMIT 1;
|
|
||||||
SELECT id INTO v_functional_id FROM training_types WHERE subcategory = 'functional' LIMIT 1;
|
|
||||||
SELECT id INTO v_hiit_id FROM training_types WHERE subcategory = 'hiit' LIMIT 1;
|
|
||||||
SELECT id INTO v_yoga_id FROM training_types WHERE subcategory = 'yoga' LIMIT 1;
|
|
||||||
SELECT id INTO v_technique_id FROM training_types WHERE subcategory = 'technique' LIMIT 1;
|
|
||||||
SELECT id INTO v_sparring_id FROM training_types WHERE subcategory = 'sparring' LIMIT 1;
|
|
||||||
SELECT id INTO v_rowing_id FROM training_types WHERE subcategory = 'rowing' LIMIT 1;
|
|
||||||
SELECT id INTO v_dance_id FROM training_types WHERE subcategory = 'dance' LIMIT 1;
|
|
||||||
SELECT id INTO v_static_id FROM training_types WHERE subcategory = 'static' LIMIT 1;
|
|
||||||
SELECT id INTO v_regeneration_id FROM training_types WHERE subcategory = 'regeneration' LIMIT 1;
|
|
||||||
SELECT id INTO v_meditation_id FROM training_types WHERE subcategory = 'meditation' LIMIT 1;
|
|
||||||
SELECT id INTO v_mindfulness_id FROM training_types WHERE subcategory = 'mindfulness' LIMIT 1;
|
|
||||||
|
|
||||||
-- Insert default mappings (German Apple Health names)
|
|
||||||
INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES
|
|
||||||
-- German workout types
|
|
||||||
('Laufen', v_running_id, NULL, 'default'),
|
|
||||||
('Gehen', v_walk_id, NULL, 'default'),
|
|
||||||
('Wandern', v_walk_id, NULL, 'default'),
|
|
||||||
('Outdoor Spaziergang', v_walk_id, NULL, 'default'),
|
|
||||||
('Innenräume Spaziergang', v_walk_id, NULL, 'default'),
|
|
||||||
('Spaziergang', v_walk_id, NULL, 'default'),
|
|
||||||
('Radfahren', v_cycling_id, NULL, 'default'),
|
|
||||||
('Schwimmen', v_swimming_id, NULL, 'default'),
|
|
||||||
('Traditionelles Krafttraining', v_hypertrophy_id, NULL, 'default'),
|
|
||||||
('Funktionelles Krafttraining', v_functional_id, NULL, 'default'),
|
|
||||||
('Hochintensives Intervalltraining', v_hiit_id, NULL, 'default'),
|
|
||||||
('Yoga', v_yoga_id, NULL, 'default'),
|
|
||||||
('Kampfsport', v_technique_id, NULL, 'default'),
|
|
||||||
('Matrial Arts', v_technique_id, NULL, 'default'), -- Common typo
|
|
||||||
('Boxen', v_sparring_id, NULL, 'default'),
|
|
||||||
('Rudern', v_rowing_id, NULL, 'default'),
|
|
||||||
('Tanzen', v_dance_id, NULL, 'default'),
|
|
||||||
('Cardio Dance', v_dance_id, NULL, 'default'),
|
|
||||||
('Flexibilität', v_static_id, NULL, 'default'),
|
|
||||||
('Abwärmen', v_regeneration_id, NULL, 'default'),
|
|
||||||
('Cooldown', v_regeneration_id, NULL, 'default'),
|
|
||||||
('Meditation', v_meditation_id, NULL, 'default'),
|
|
||||||
('Achtsamkeit', v_mindfulness_id, NULL, 'default'),
|
|
||||||
('Geist & Körper', v_yoga_id, NULL, 'default')
|
|
||||||
ON CONFLICT (activity_type, profile_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- English workout types (for compatibility)
|
|
||||||
INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES
|
|
||||||
('Running', v_running_id, NULL, 'default'),
|
|
||||||
('Walking', v_walk_id, NULL, 'default'),
|
|
||||||
('Hiking', v_walk_id, NULL, 'default'),
|
|
||||||
('Cycling', v_cycling_id, NULL, 'default'),
|
|
||||||
('Swimming', v_swimming_id, NULL, 'default'),
|
|
||||||
('Traditional Strength Training', v_hypertrophy_id, NULL, 'default'),
|
|
||||||
('Functional Strength Training', v_functional_id, NULL, 'default'),
|
|
||||||
('High Intensity Interval Training', v_hiit_id, NULL, 'default'),
|
|
||||||
('Martial Arts', v_technique_id, NULL, 'default'),
|
|
||||||
('Boxing', v_sparring_id, NULL, 'default'),
|
|
||||||
('Rowing', v_rowing_id, NULL, 'default'),
|
|
||||||
('Dance', v_dance_id, NULL, 'default'),
|
|
||||||
('Core Training', v_functional_id, NULL, 'default'),
|
|
||||||
('Flexibility', v_static_id, NULL, 'default'),
|
|
||||||
('Mindfulness', v_mindfulness_id, NULL, 'default')
|
|
||||||
ON CONFLICT (activity_type, profile_id) DO NOTHING;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 4. Add comment
|
|
||||||
-- ========================================
|
|
||||||
COMMENT ON TABLE activity_type_mappings IS 'v9d Phase 1b: Learnable activity type to training type mappings. Replaces hardcoded mappings.';
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
-- Migration 008: Vitals, Rest Days, Weekly Goals
|
|
||||||
-- v9d Phase 2: Sleep & Vitals Module
|
|
||||||
-- Date: 2026-03-22
|
|
||||||
|
|
||||||
-- Rest Days
|
|
||||||
CREATE TABLE IF NOT EXISTS rest_days (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
type VARCHAR(20) NOT NULL CHECK (type IN ('full_rest', 'active_recovery')),
|
|
||||||
note TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT unique_rest_day_per_profile UNIQUE(profile_id, date)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_rest_days_profile_date ON rest_days(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- Vitals (Resting HR + HRV)
|
|
||||||
CREATE TABLE IF NOT EXISTS vitals_log (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
resting_hr INTEGER CHECK (resting_hr > 0 AND resting_hr < 200),
|
|
||||||
hrv INTEGER CHECK (hrv > 0),
|
|
||||||
note TEXT,
|
|
||||||
source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin')),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT unique_vitals_per_day UNIQUE(profile_id, date)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_vitals_profile_date ON vitals_log(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- Extend activity_log for heart rate data
|
|
||||||
ALTER TABLE activity_log
|
|
||||||
ADD COLUMN IF NOT EXISTS avg_hr INTEGER CHECK (avg_hr > 0 AND avg_hr < 250),
|
|
||||||
ADD COLUMN IF NOT EXISTS max_hr INTEGER CHECK (max_hr > 0 AND max_hr < 250);
|
|
||||||
|
|
||||||
-- Extend profiles for HF max and sleep goal
|
|
||||||
ALTER TABLE profiles
|
|
||||||
ADD COLUMN IF NOT EXISTS hf_max INTEGER CHECK (hf_max > 0 AND hf_max < 250),
|
|
||||||
ADD COLUMN IF NOT EXISTS sleep_goal_minutes INTEGER DEFAULT 450 CHECK (sleep_goal_minutes > 0);
|
|
||||||
|
|
||||||
-- Weekly Goals (Soll/Ist Wochenplanung)
|
|
||||||
CREATE TABLE IF NOT EXISTS weekly_goals (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
week_start DATE NOT NULL,
|
|
||||||
goals JSONB NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT unique_weekly_goal_per_profile UNIQUE(profile_id, week_start)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_weekly_goals_profile_week ON weekly_goals(profile_id, week_start DESC);
|
|
||||||
|
|
||||||
-- Comments for documentation
|
|
||||||
COMMENT ON TABLE rest_days IS 'v9d Phase 2: Rest days tracking (full rest or active recovery)';
|
|
||||||
COMMENT ON TABLE vitals_log IS 'v9d Phase 2: Daily vitals (resting HR, HRV)';
|
|
||||||
COMMENT ON TABLE weekly_goals IS 'v9d Phase 2: Weekly training goals (Soll/Ist planning)';
|
|
||||||
COMMENT ON COLUMN profiles.hf_max IS 'Maximum heart rate for HR zone calculation';
|
|
||||||
COMMENT ON COLUMN profiles.sleep_goal_minutes IS 'Sleep goal in minutes (default: 450 = 7h 30min)';
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
-- Migration 009: Sleep Log Table
|
|
||||||
-- v9d Phase 2b: Sleep Module Core
|
|
||||||
-- Date: 2026-03-22
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sleep_log (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
bedtime TIME,
|
|
||||||
wake_time TIME,
|
|
||||||
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
|
|
||||||
quality INTEGER CHECK (quality >= 1 AND quality <= 5),
|
|
||||||
wake_count INTEGER CHECK (wake_count >= 0),
|
|
||||||
deep_minutes INTEGER CHECK (deep_minutes >= 0),
|
|
||||||
rem_minutes INTEGER CHECK (rem_minutes >= 0),
|
|
||||||
light_minutes INTEGER CHECK (light_minutes >= 0),
|
|
||||||
awake_minutes INTEGER CHECK (awake_minutes >= 0),
|
|
||||||
sleep_segments JSONB,
|
|
||||||
note TEXT,
|
|
||||||
source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin')),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT unique_sleep_per_day UNIQUE(profile_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_sleep_profile_date ON sleep_log(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- Comments for documentation
|
|
||||||
COMMENT ON TABLE sleep_log IS 'v9d Phase 2b: Daily sleep tracking with phase data';
|
|
||||||
COMMENT ON COLUMN sleep_log.date IS 'Date of the night (wake date, not bedtime date)';
|
|
||||||
COMMENT ON COLUMN sleep_log.sleep_segments IS 'Raw phase segments: [{"phase": "deep", "start": "23:44", "duration_min": 42}, ...]';
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
-- Migration 010: Rest Days Refactoring zu JSONB
|
|
||||||
-- v9d Phase 2a: Flexible, context-specific rest days
|
|
||||||
-- Date: 2026-03-22
|
|
||||||
|
|
||||||
-- Refactor rest_days to JSONB config for flexible rest day types
|
|
||||||
-- OLD: type VARCHAR(20) CHECK (type IN ('full_rest', 'active_recovery'))
|
|
||||||
-- NEW: rest_config JSONB with {focus, rest_from[], allows[], intensity_max}
|
|
||||||
|
|
||||||
-- Drop old type column
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
DROP COLUMN IF EXISTS type;
|
|
||||||
|
|
||||||
-- Add new JSONB config column
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
ADD COLUMN IF NOT EXISTS rest_config JSONB NOT NULL DEFAULT '{"focus": "mental_rest", "rest_from": [], "allows": []}'::jsonb;
|
|
||||||
|
|
||||||
-- Validation function for rest_config
|
|
||||||
CREATE OR REPLACE FUNCTION validate_rest_config(config JSONB) RETURNS BOOLEAN AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Must have focus field
|
|
||||||
IF NOT (config ? 'focus') THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- focus must be one of the allowed values
|
|
||||||
IF NOT (config->>'focus' IN ('muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury')) THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- rest_from must be array if present
|
|
||||||
IF (config ? 'rest_from') AND jsonb_typeof(config->'rest_from') != 'array' THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- allows must be array if present
|
|
||||||
IF (config ? 'allows') AND jsonb_typeof(config->'allows') != 'array' THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- intensity_max must be number between 1-100 if present
|
|
||||||
IF (config ? 'intensity_max') AND (
|
|
||||||
jsonb_typeof(config->'intensity_max') != 'number' OR
|
|
||||||
(config->>'intensity_max')::int < 1 OR
|
|
||||||
(config->>'intensity_max')::int > 100
|
|
||||||
) THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN TRUE;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Add check constraint
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
ADD CONSTRAINT valid_rest_config CHECK (validate_rest_config(rest_config));
|
|
||||||
|
|
||||||
-- Add comment for documentation
|
|
||||||
COMMENT ON COLUMN rest_days.rest_config IS 'JSONB: {focus: string, rest_from: string[], allows: string[], intensity_max?: number (1-100), note?: string}';
|
|
||||||
COMMENT ON TABLE rest_days IS 'v9d Phase 2a: Context-specific rest days (strength rest but cardio allowed, etc.)';
|
|
||||||
|
|
||||||
-- Create GIN index on rest_config for faster JSONB queries
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rest_days_config ON rest_days USING GIN (rest_config);
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
-- Migration 011: Allow Multiple Rest Days per Date
|
|
||||||
-- v9d Phase 2a: Support for multi-dimensional rest (development routes)
|
|
||||||
-- Date: 2026-03-22
|
|
||||||
|
|
||||||
-- Remove UNIQUE constraint to allow multiple rest day types per date
|
|
||||||
-- Use Case: Muscle recovery + Mental rest on same day
|
|
||||||
-- Future: Development routes (Conditioning, Strength, Coordination, Mental, Mobility, Technique)
|
|
||||||
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
DROP CONSTRAINT IF EXISTS unique_rest_day_per_profile;
|
|
||||||
|
|
||||||
-- Add index for efficient queries (profile_id, date)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rest_days_profile_date_multi
|
|
||||||
ON rest_days(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- Comment for documentation
|
|
||||||
COMMENT ON TABLE rest_days IS 'v9d Phase 2a: Multi-dimensional rest days - multiple entries per date allowed for different development routes (muscle, cardio, mental, coordination, technique)';
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
-- Migration 012: Unique constraint on (profile_id, date, focus)
|
|
||||||
-- v9d Phase 2a: Prevent duplicate rest day types per date
|
|
||||||
-- Date: 2026-03-22
|
|
||||||
|
|
||||||
-- Add focus column (extracted from rest_config for performance + constraints)
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
ADD COLUMN IF NOT EXISTS focus VARCHAR(20);
|
|
||||||
|
|
||||||
-- Populate from existing JSONB data
|
|
||||||
UPDATE rest_days
|
|
||||||
SET focus = rest_config->>'focus'
|
|
||||||
WHERE focus IS NULL;
|
|
||||||
|
|
||||||
-- Make NOT NULL (safe because we just populated all rows)
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
ALTER COLUMN focus SET NOT NULL;
|
|
||||||
|
|
||||||
-- Add CHECK constraint for valid focus values
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
ADD CONSTRAINT valid_focus CHECK (
|
|
||||||
focus IN ('muscle_recovery', 'cardio_recovery', 'mental_rest', 'deload', 'injury')
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add UNIQUE constraint: Same profile + date + focus = duplicate
|
|
||||||
ALTER TABLE rest_days
|
|
||||||
ADD CONSTRAINT unique_rest_day_per_focus
|
|
||||||
UNIQUE (profile_id, date, focus);
|
|
||||||
|
|
||||||
-- Add index for efficient queries by focus
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rest_days_focus
|
|
||||||
ON rest_days(focus);
|
|
||||||
|
|
||||||
-- Comment for documentation
|
|
||||||
COMMENT ON COLUMN rest_days.focus IS 'Extracted from rest_config.focus for performance and constraints. Prevents duplicate rest day types per date.';
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
-- Migration 013: Training Parameters Registry
|
|
||||||
-- Training Type Profiles System - Foundation
|
|
||||||
-- Date: 2026-03-23
|
|
||||||
-- Issue: #15
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- TRAINING PARAMETERS REGISTRY
|
|
||||||
-- Zentrale Definition aller messbaren Parameter für Aktivitäten
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS training_parameters (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
key VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
name_de VARCHAR(100) NOT NULL,
|
|
||||||
name_en VARCHAR(100) NOT NULL,
|
|
||||||
category VARCHAR(50) NOT NULL,
|
|
||||||
data_type VARCHAR(20) NOT NULL,
|
|
||||||
unit VARCHAR(20),
|
|
||||||
description_de TEXT,
|
|
||||||
description_en TEXT,
|
|
||||||
source_field VARCHAR(100),
|
|
||||||
validation_rules JSONB DEFAULT '{}'::jsonb,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT chk_category CHECK (category IN (
|
|
||||||
'physical', 'physiological', 'subjective', 'environmental', 'performance'
|
|
||||||
)),
|
|
||||||
CONSTRAINT chk_data_type CHECK (data_type IN (
|
|
||||||
'integer', 'float', 'string', 'boolean'
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_training_parameters_category ON training_parameters(category) WHERE is_active = true;
|
|
||||||
CREATE INDEX idx_training_parameters_key ON training_parameters(key) WHERE is_active = true;
|
|
||||||
|
|
||||||
COMMENT ON TABLE training_parameters IS 'Registry of all measurable activity parameters (Training Type Profiles System)';
|
|
||||||
COMMENT ON COLUMN training_parameters.key IS 'Unique identifier (e.g. "avg_hr", "duration_min")';
|
|
||||||
COMMENT ON COLUMN training_parameters.category IS 'Parameter category: physical, physiological, subjective, environmental, performance';
|
|
||||||
COMMENT ON COLUMN training_parameters.data_type IS 'Data type: integer, float, string, boolean';
|
|
||||||
COMMENT ON COLUMN training_parameters.source_field IS 'Mapping to activity_log column name';
|
|
||||||
COMMENT ON COLUMN training_parameters.validation_rules IS 'Min/Max/Enum for validation (JSONB)';
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- STANDARD PARAMETERS
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
INSERT INTO training_parameters (key, name_de, name_en, category, data_type, unit, source_field, validation_rules, description_de, description_en) VALUES
|
|
||||||
|
|
||||||
-- Physical Parameters
|
|
||||||
('duration_min', 'Dauer', 'Duration', 'physical', 'integer', 'min', 'duration_min',
|
|
||||||
'{"min": 0, "max": 600}'::jsonb,
|
|
||||||
'Trainingsdauer in Minuten',
|
|
||||||
'Training duration in minutes'),
|
|
||||||
|
|
||||||
('distance_km', 'Distanz', 'Distance', 'physical', 'float', 'km', 'distance_km',
|
|
||||||
'{"min": 0, "max": 200}'::jsonb,
|
|
||||||
'Zurückgelegte Distanz in Kilometern',
|
|
||||||
'Distance covered in kilometers'),
|
|
||||||
|
|
||||||
('kcal_active', 'Aktive Kalorien', 'Active Calories', 'physical', 'integer', 'kcal', 'kcal_active',
|
|
||||||
'{"min": 0, "max": 5000}'::jsonb,
|
|
||||||
'Aktiver Kalorienverbrauch',
|
|
||||||
'Active calorie burn'),
|
|
||||||
|
|
||||||
('kcal_resting', 'Ruhekalorien', 'Resting Calories', 'physical', 'integer', 'kcal', 'kcal_resting',
|
|
||||||
'{"min": 0, "max": 2000}'::jsonb,
|
|
||||||
'Ruheumsatz während Training',
|
|
||||||
'Resting calorie burn during training'),
|
|
||||||
|
|
||||||
('elevation_gain', 'Höhenmeter', 'Elevation Gain', 'physical', 'integer', 'm', 'elevation_gain',
|
|
||||||
'{"min": 0, "max": 5000}'::jsonb,
|
|
||||||
'Überwundene Höhenmeter',
|
|
||||||
'Elevation gain in meters'),
|
|
||||||
|
|
||||||
('pace_min_per_km', 'Pace', 'Pace', 'physical', 'float', 'min/km', 'pace_min_per_km',
|
|
||||||
'{"min": 2, "max": 20}'::jsonb,
|
|
||||||
'Durchschnittstempo in Minuten pro Kilometer',
|
|
||||||
'Average pace in minutes per kilometer'),
|
|
||||||
|
|
||||||
('cadence', 'Trittfrequenz', 'Cadence', 'physical', 'integer', 'spm', 'cadence',
|
|
||||||
'{"min": 0, "max": 220}'::jsonb,
|
|
||||||
'Schrittfrequenz (Schritte pro Minute)',
|
|
||||||
'Step frequency (steps per minute)'),
|
|
||||||
|
|
||||||
-- Physiological Parameters
|
|
||||||
('avg_hr', 'Durchschnittspuls', 'Average Heart Rate', 'physiological', 'integer', 'bpm', 'hr_avg',
|
|
||||||
'{"min": 30, "max": 220}'::jsonb,
|
|
||||||
'Durchschnittliche Herzfrequenz',
|
|
||||||
'Average heart rate'),
|
|
||||||
|
|
||||||
('max_hr', 'Maximalpuls', 'Max Heart Rate', 'physiological', 'integer', 'bpm', 'hr_max',
|
|
||||||
'{"min": 40, "max": 220}'::jsonb,
|
|
||||||
'Maximale Herzfrequenz',
|
|
||||||
'Maximum heart rate'),
|
|
||||||
|
|
||||||
('min_hr', 'Minimalpuls', 'Min Heart Rate', 'physiological', 'integer', 'bpm', 'hr_min',
|
|
||||||
'{"min": 30, "max": 200}'::jsonb,
|
|
||||||
'Minimale Herzfrequenz',
|
|
||||||
'Minimum heart rate'),
|
|
||||||
|
|
||||||
('avg_power', 'Durchschnittsleistung', 'Average Power', 'physiological', 'integer', 'W', 'avg_power',
|
|
||||||
'{"min": 0, "max": 1000}'::jsonb,
|
|
||||||
'Durchschnittliche Leistung in Watt',
|
|
||||||
'Average power output in watts'),
|
|
||||||
|
|
||||||
-- Subjective Parameters
|
|
||||||
('rpe', 'RPE (Anstrengung)', 'RPE (Perceived Exertion)', 'subjective', 'integer', 'scale', 'rpe',
|
|
||||||
'{"min": 1, "max": 10}'::jsonb,
|
|
||||||
'Subjektive Anstrengung (Rate of Perceived Exertion)',
|
|
||||||
'Rate of Perceived Exertion'),
|
|
||||||
|
|
||||||
-- Environmental Parameters
|
|
||||||
('temperature_celsius', 'Temperatur', 'Temperature', 'environmental', 'float', '°C', 'temperature_celsius',
|
|
||||||
'{"min": -30, "max": 50}'::jsonb,
|
|
||||||
'Umgebungstemperatur in Celsius',
|
|
||||||
'Ambient temperature in Celsius'),
|
|
||||||
|
|
||||||
('humidity_percent', 'Luftfeuchtigkeit', 'Humidity', 'environmental', 'integer', '%', 'humidity_percent',
|
|
||||||
'{"min": 0, "max": 100}'::jsonb,
|
|
||||||
'Relative Luftfeuchtigkeit in Prozent',
|
|
||||||
'Relative humidity in percent'),
|
|
||||||
|
|
||||||
-- Performance Parameters (calculated)
|
|
||||||
('avg_hr_percent', '% Max-HF', '% Max HR', 'performance', 'float', '%', 'avg_hr_percent',
|
|
||||||
'{"min": 0, "max": 100}'::jsonb,
|
|
||||||
'Durchschnittspuls als Prozent der maximalen Herzfrequenz',
|
|
||||||
'Average heart rate as percentage of max heart rate'),
|
|
||||||
|
|
||||||
('kcal_per_km', 'Kalorien pro km', 'Calories per km', 'performance', 'float', 'kcal/km', 'kcal_per_km',
|
|
||||||
'{"min": 0, "max": 1000}'::jsonb,
|
|
||||||
'Kalorienverbrauch pro Kilometer',
|
|
||||||
'Calorie burn per kilometer');
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- SUMMARY
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
-- Display inserted parameters
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE '✓ Migration 013 completed';
|
|
||||||
RAISE NOTICE ' - Created training_parameters table';
|
|
||||||
RAISE NOTICE ' - Inserted % standard parameters', (SELECT COUNT(*) FROM training_parameters);
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
-- Migration 014: Training Type Profiles & Activity Evaluation
|
|
||||||
-- Training Type Profiles System - Schema Extensions
|
|
||||||
-- Date: 2026-03-23
|
|
||||||
-- Issue: #15
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- EXTEND TRAINING TYPES
|
|
||||||
-- Add profile column for comprehensive training type configuration
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
ALTER TABLE training_types ADD COLUMN IF NOT EXISTS profile JSONB DEFAULT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_training_types_profile_enabled ON training_types
|
|
||||||
((profile->'rule_sets'->'minimum_requirements'->>'enabled'))
|
|
||||||
WHERE profile IS NOT NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN training_types.profile IS 'Comprehensive training type profile with 7 dimensions (rule_sets, intensity_zones, training_effects, periodization, performance_indicators, safety, ai_context)';
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- EXTEND ACTIVITY LOG
|
|
||||||
-- Add evaluation results and quality labels
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS evaluation JSONB DEFAULT NULL;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS quality_label VARCHAR(20);
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS overall_score FLOAT;
|
|
||||||
|
|
||||||
CREATE INDEX idx_activity_quality_label ON activity_log(quality_label)
|
|
||||||
WHERE quality_label IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_activity_overall_score ON activity_log(overall_score DESC)
|
|
||||||
WHERE overall_score IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_activity_evaluation_passed ON activity_log
|
|
||||||
((evaluation->'rule_set_results'->'minimum_requirements'->>'passed'))
|
|
||||||
WHERE evaluation IS NOT NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN activity_log.evaluation IS 'Complete evaluation result (7 dimensions, scores, recommendations, warnings)';
|
|
||||||
COMMENT ON COLUMN activity_log.quality_label IS 'Quality label: excellent, good, acceptable, poor (for quick filtering)';
|
|
||||||
COMMENT ON COLUMN activity_log.overall_score IS 'Overall quality score 0.0-1.0 (for sorting)';
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- ADD MISSING COLUMNS (if not already added by previous migrations)
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
-- Add HR columns if not exist (might be in Migration 008)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name='activity_log' AND column_name='hr_min') THEN
|
|
||||||
ALTER TABLE activity_log ADD COLUMN hr_min INTEGER CHECK (hr_min > 0 AND hr_min < 200);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- Add performance columns for calculated values
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS avg_hr_percent FLOAT;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS kcal_per_km FLOAT;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS pace_min_per_km FLOAT;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS cadence INTEGER;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS avg_power INTEGER;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS elevation_gain INTEGER;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS temperature_celsius FLOAT;
|
|
||||||
ALTER TABLE activity_log ADD COLUMN IF NOT EXISTS humidity_percent INTEGER;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN activity_log.avg_hr_percent IS 'Average HR as percentage of user max HR (calculated)';
|
|
||||||
COMMENT ON COLUMN activity_log.kcal_per_km IS 'Calories burned per kilometer (calculated)';
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- HELPER FUNCTION: Calculate avg_hr_percent
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION calculate_avg_hr_percent()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
user_max_hr INTEGER;
|
|
||||||
BEGIN
|
|
||||||
-- Get user's max HR from profile
|
|
||||||
SELECT hf_max INTO user_max_hr
|
|
||||||
FROM profiles
|
|
||||||
WHERE id = NEW.profile_id;
|
|
||||||
|
|
||||||
-- Calculate percentage if both values exist
|
|
||||||
IF NEW.hr_avg IS NOT NULL AND user_max_hr IS NOT NULL AND user_max_hr > 0 THEN
|
|
||||||
NEW.avg_hr_percent := (NEW.hr_avg::float / user_max_hr::float) * 100;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Calculate kcal per km
|
|
||||||
IF NEW.kcal_active IS NOT NULL AND NEW.distance_km IS NOT NULL AND NEW.distance_km > 0 THEN
|
|
||||||
NEW.kcal_per_km := NEW.kcal_active::float / NEW.distance_km;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger for automatic calculation
|
|
||||||
DROP TRIGGER IF EXISTS trg_calculate_performance_metrics ON activity_log;
|
|
||||||
CREATE TRIGGER trg_calculate_performance_metrics
|
|
||||||
BEFORE INSERT OR UPDATE ON activity_log
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION calculate_avg_hr_percent();
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- SUMMARY
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE '✓ Migration 014 completed';
|
|
||||||
RAISE NOTICE ' - Extended training_types with profile column';
|
|
||||||
RAISE NOTICE ' - Extended activity_log with evaluation columns';
|
|
||||||
RAISE NOTICE ' - Added performance metric calculations';
|
|
||||||
RAISE NOTICE ' - Created indexes for fast queries';
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
-- Migration 014: Extended Vitals (Blood Pressure, VO2 Max, SpO2, Respiratory Rate)
|
|
||||||
-- v9d Phase 2d: Complete vitals tracking
|
|
||||||
-- Date: 2026-03-23
|
|
||||||
|
|
||||||
-- Add new vitals fields
|
|
||||||
ALTER TABLE vitals_log
|
|
||||||
ADD COLUMN IF NOT EXISTS blood_pressure_systolic INTEGER CHECK (blood_pressure_systolic > 0 AND blood_pressure_systolic < 300),
|
|
||||||
ADD COLUMN IF NOT EXISTS blood_pressure_diastolic INTEGER CHECK (blood_pressure_diastolic > 0 AND blood_pressure_diastolic < 200),
|
|
||||||
ADD COLUMN IF NOT EXISTS pulse INTEGER CHECK (pulse > 0 AND pulse < 250),
|
|
||||||
ADD COLUMN IF NOT EXISTS vo2_max DECIMAL(4,1) CHECK (vo2_max > 0 AND vo2_max < 100),
|
|
||||||
ADD COLUMN IF NOT EXISTS spo2 INTEGER CHECK (spo2 >= 70 AND spo2 <= 100),
|
|
||||||
ADD COLUMN IF NOT EXISTS respiratory_rate DECIMAL(4,1) CHECK (respiratory_rate > 0 AND respiratory_rate < 60),
|
|
||||||
ADD COLUMN IF NOT EXISTS irregular_heartbeat BOOLEAN DEFAULT false,
|
|
||||||
ADD COLUMN IF NOT EXISTS possible_afib BOOLEAN DEFAULT false;
|
|
||||||
|
|
||||||
-- Update source check to include omron
|
|
||||||
ALTER TABLE vitals_log DROP CONSTRAINT IF EXISTS vitals_log_source_check;
|
|
||||||
ALTER TABLE vitals_log ADD CONSTRAINT vitals_log_source_check
|
|
||||||
CHECK (source IN ('manual', 'apple_health', 'garmin', 'omron'));
|
|
||||||
|
|
||||||
-- Comments
|
|
||||||
COMMENT ON COLUMN vitals_log.blood_pressure_systolic IS 'Systolic blood pressure (mmHg) from Omron or manual entry';
|
|
||||||
COMMENT ON COLUMN vitals_log.blood_pressure_diastolic IS 'Diastolic blood pressure (mmHg) from Omron or manual entry';
|
|
||||||
COMMENT ON COLUMN vitals_log.pulse IS 'Pulse during blood pressure measurement (bpm)';
|
|
||||||
COMMENT ON COLUMN vitals_log.vo2_max IS 'VO2 Max from Apple Watch (ml/kg/min)';
|
|
||||||
COMMENT ON COLUMN vitals_log.spo2 IS 'Blood oxygen saturation (%) from Apple Watch';
|
|
||||||
COMMENT ON COLUMN vitals_log.respiratory_rate IS 'Respiratory rate (breaths/min) from Apple Watch';
|
|
||||||
COMMENT ON COLUMN vitals_log.irregular_heartbeat IS 'Irregular heartbeat detected (Omron)';
|
|
||||||
COMMENT ON COLUMN vitals_log.possible_afib IS 'Possible atrial fibrillation (Omron)';
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
-- Migration 015: Vitals Refactoring - Trennung Baseline vs. Context-Dependent
|
|
||||||
-- v9d Phase 2d: Architektur-Verbesserung für bessere Datenqualität
|
|
||||||
-- Date: 2026-03-23
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
-- STEP 1: Create new tables
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Baseline Vitals (slow-changing, once daily, morning measurement)
|
|
||||||
CREATE TABLE IF NOT EXISTS vitals_baseline (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Core baseline vitals
|
|
||||||
resting_hr INTEGER CHECK (resting_hr > 0 AND resting_hr < 120),
|
|
||||||
hrv INTEGER CHECK (hrv > 0 AND hrv < 300),
|
|
||||||
vo2_max DECIMAL(4,1) CHECK (vo2_max > 0 AND vo2_max < 100),
|
|
||||||
spo2 INTEGER CHECK (spo2 >= 70 AND spo2 <= 100),
|
|
||||||
respiratory_rate DECIMAL(4,1) CHECK (respiratory_rate > 0 AND respiratory_rate < 60),
|
|
||||||
|
|
||||||
-- Future baseline vitals (prepared for expansion)
|
|
||||||
body_temperature DECIMAL(3,1) CHECK (body_temperature > 30 AND body_temperature < 45),
|
|
||||||
resting_metabolic_rate INTEGER CHECK (resting_metabolic_rate > 0),
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
note TEXT,
|
|
||||||
source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'apple_health', 'garmin', 'withings')),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT unique_baseline_per_day UNIQUE(profile_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_vitals_baseline_profile_date ON vitals_baseline(profile_id, date DESC);
|
|
||||||
|
|
||||||
COMMENT ON TABLE vitals_baseline IS 'v9d Phase 2d: Baseline vitals measured once daily (morning, fasted)';
|
|
||||||
COMMENT ON COLUMN vitals_baseline.resting_hr IS 'Resting heart rate (bpm) - measured in the morning before getting up';
|
|
||||||
COMMENT ON COLUMN vitals_baseline.hrv IS 'Heart rate variability (ms) - higher is better';
|
|
||||||
COMMENT ON COLUMN vitals_baseline.vo2_max IS 'VO2 Max (ml/kg/min) - estimated by Apple Watch or lab test';
|
|
||||||
COMMENT ON COLUMN vitals_baseline.spo2 IS 'Blood oxygen saturation (%) - baseline measurement';
|
|
||||||
COMMENT ON COLUMN vitals_baseline.respiratory_rate IS 'Respiratory rate (breaths/min) - baseline measurement';
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Blood Pressure Log (context-dependent, multiple times per day)
|
|
||||||
CREATE TABLE IF NOT EXISTS blood_pressure_log (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
measured_at TIMESTAMP NOT NULL,
|
|
||||||
|
|
||||||
-- Blood pressure measurements
|
|
||||||
systolic INTEGER NOT NULL CHECK (systolic > 0 AND systolic < 300),
|
|
||||||
diastolic INTEGER NOT NULL CHECK (diastolic > 0 AND diastolic < 200),
|
|
||||||
pulse INTEGER CHECK (pulse > 0 AND pulse < 250),
|
|
||||||
|
|
||||||
-- Context tagging for correlation analysis
|
|
||||||
context VARCHAR(30) CHECK (context IN (
|
|
||||||
'morning_fasted', -- Morgens nüchtern
|
|
||||||
'after_meal', -- Nach dem Essen
|
|
||||||
'before_training', -- Vor dem Training
|
|
||||||
'after_training', -- Nach dem Training
|
|
||||||
'evening', -- Abends
|
|
||||||
'stress', -- Bei Stress
|
|
||||||
'resting', -- Ruhemessung
|
|
||||||
'other' -- Sonstiges
|
|
||||||
)),
|
|
||||||
|
|
||||||
-- Warning flags (Omron)
|
|
||||||
irregular_heartbeat BOOLEAN DEFAULT false,
|
|
||||||
possible_afib BOOLEAN DEFAULT false,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
note TEXT,
|
|
||||||
source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'omron', 'apple_health', 'withings')),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT unique_bp_measurement UNIQUE(profile_id, measured_at)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_blood_pressure_profile_datetime ON blood_pressure_log(profile_id, measured_at DESC);
|
|
||||||
CREATE INDEX idx_blood_pressure_context ON blood_pressure_log(context) WHERE context IS NOT NULL;
|
|
||||||
|
|
||||||
COMMENT ON TABLE blood_pressure_log IS 'v9d Phase 2d: Blood pressure measurements (multiple per day, context-aware)';
|
|
||||||
COMMENT ON COLUMN blood_pressure_log.context IS 'Measurement context for correlation analysis';
|
|
||||||
COMMENT ON COLUMN blood_pressure_log.irregular_heartbeat IS 'Irregular heartbeat detected (Omron device)';
|
|
||||||
COMMENT ON COLUMN blood_pressure_log.possible_afib IS 'Possible atrial fibrillation (Omron device)';
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
-- STEP 2: Migrate existing data from vitals_log
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Migrate baseline vitals (RHR, HRV, VO2 Max, SpO2, Respiratory Rate)
|
|
||||||
INSERT INTO vitals_baseline (
|
|
||||||
profile_id, date,
|
|
||||||
resting_hr, hrv, vo2_max, spo2, respiratory_rate,
|
|
||||||
note, source, created_at, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
profile_id, date,
|
|
||||||
resting_hr, hrv, vo2_max, spo2, respiratory_rate,
|
|
||||||
note, source, created_at, updated_at
|
|
||||||
FROM vitals_log
|
|
||||||
WHERE resting_hr IS NOT NULL
|
|
||||||
OR hrv IS NOT NULL
|
|
||||||
OR vo2_max IS NOT NULL
|
|
||||||
OR spo2 IS NOT NULL
|
|
||||||
OR respiratory_rate IS NOT NULL
|
|
||||||
ON CONFLICT (profile_id, date) DO NOTHING;
|
|
||||||
|
|
||||||
-- Migrate blood pressure measurements
|
|
||||||
-- Note: Use date + 08:00 as default timestamp (morning measurement)
|
|
||||||
INSERT INTO blood_pressure_log (
|
|
||||||
profile_id, measured_at,
|
|
||||||
systolic, diastolic, pulse,
|
|
||||||
irregular_heartbeat, possible_afib,
|
|
||||||
note, source, created_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
profile_id,
|
|
||||||
(date + TIME '08:00:00')::timestamp AS measured_at,
|
|
||||||
blood_pressure_systolic,
|
|
||||||
blood_pressure_diastolic,
|
|
||||||
pulse,
|
|
||||||
irregular_heartbeat,
|
|
||||||
possible_afib,
|
|
||||||
note,
|
|
||||||
CASE
|
|
||||||
WHEN source = 'manual' THEN 'manual'
|
|
||||||
WHEN source = 'omron' THEN 'omron'
|
|
||||||
ELSE 'manual'
|
|
||||||
END AS source,
|
|
||||||
created_at
|
|
||||||
FROM vitals_log
|
|
||||||
WHERE blood_pressure_systolic IS NOT NULL
|
|
||||||
AND blood_pressure_diastolic IS NOT NULL
|
|
||||||
ON CONFLICT (profile_id, measured_at) DO NOTHING;
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
-- STEP 3: Drop old vitals_log table (backup first)
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Rename old table as backup (keep for safety, can be dropped later)
|
|
||||||
ALTER TABLE vitals_log RENAME TO vitals_log_backup_pre_015;
|
|
||||||
|
|
||||||
-- Drop old index (it's on the renamed table now)
|
|
||||||
DROP INDEX IF EXISTS idx_vitals_profile_date;
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
-- STEP 4: Prepared for future vitals types
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Future tables (commented out, create when needed):
|
|
||||||
|
|
||||||
-- Glucose Log (for blood sugar tracking)
|
|
||||||
-- CREATE TABLE glucose_log (
|
|
||||||
-- id SERIAL PRIMARY KEY,
|
|
||||||
-- profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
-- measured_at TIMESTAMP NOT NULL,
|
|
||||||
-- glucose_mg_dl INTEGER NOT NULL CHECK (glucose_mg_dl > 0 AND glucose_mg_dl < 500),
|
|
||||||
-- context VARCHAR(30) CHECK (context IN (
|
|
||||||
-- 'fasted', 'before_meal', 'after_meal_1h', 'after_meal_2h', 'before_training', 'after_training', 'other'
|
|
||||||
-- )),
|
|
||||||
-- note TEXT,
|
|
||||||
-- source VARCHAR(20) DEFAULT 'manual',
|
|
||||||
-- created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
-- CONSTRAINT unique_glucose_measurement UNIQUE(profile_id, measured_at)
|
|
||||||
-- );
|
|
||||||
|
|
||||||
-- Temperature Log (for illness tracking)
|
|
||||||
-- CREATE TABLE temperature_log (
|
|
||||||
-- id SERIAL PRIMARY KEY,
|
|
||||||
-- profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
-- measured_at TIMESTAMP NOT NULL,
|
|
||||||
-- temperature_celsius DECIMAL(3,1) NOT NULL CHECK (temperature_celsius > 30 AND temperature_celsius < 45),
|
|
||||||
-- measurement_location VARCHAR(20) CHECK (measurement_location IN ('oral', 'ear', 'forehead', 'armpit')),
|
|
||||||
-- note TEXT,
|
|
||||||
-- created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
-- CONSTRAINT unique_temperature_measurement UNIQUE(profile_id, measured_at)
|
|
||||||
-- );
|
|
||||||
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
-- Migration complete
|
|
||||||
-- ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- Migration 016: Global Quality Filter Setting
|
|
||||||
-- Issue: #31
|
|
||||||
-- Date: 2026-03-23
|
|
||||||
-- Description: Add quality_filter_level to profiles for consistent data views
|
|
||||||
|
|
||||||
-- Add quality_filter_level column to profiles
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS quality_filter_level VARCHAR(20) DEFAULT 'all';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN profiles.quality_filter_level IS 'Global quality filter for all activity views: all, quality, very_good, excellent';
|
|
||||||
|
|
||||||
-- Create index for performance (if filtering becomes common)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_quality_filter ON profiles(quality_filter_level);
|
|
||||||
|
|
||||||
-- Migration tracking
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE '✓ Migration 016: Added global quality filter setting';
|
|
||||||
RAISE NOTICE ' - Added profiles.quality_filter_level column';
|
|
||||||
RAISE NOTICE ' - Default: all (no filter)';
|
|
||||||
RAISE NOTICE ' - Values: all, quality, very_good, excellent';
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-- Migration 017: AI Prompts Flexibilisierung (Issue #28)
|
|
||||||
-- Add category column to ai_prompts for better organization and filtering
|
|
||||||
|
|
||||||
-- Add category column
|
|
||||||
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS category VARCHAR(20) DEFAULT 'ganzheitlich';
|
|
||||||
|
|
||||||
-- Create index for category filtering
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
|
|
||||||
|
|
||||||
-- Add comment
|
|
||||||
COMMENT ON COLUMN ai_prompts.category IS 'Prompt category: körper, ernährung, training, schlaf, vitalwerte, ziele, ganzheitlich';
|
|
||||||
|
|
||||||
-- Update existing prompts with appropriate categories
|
|
||||||
-- Based on slug patterns and content
|
|
||||||
UPDATE ai_prompts SET category = 'körper' WHERE slug IN ('koerperkomposition', 'gewichtstrend', 'umfaenge', 'caliper');
|
|
||||||
UPDATE ai_prompts SET category = 'ernährung' WHERE slug IN ('ernaehrung', 'kalorienbilanz', 'protein', 'makros');
|
|
||||||
UPDATE ai_prompts SET category = 'training' WHERE slug IN ('aktivitaet', 'trainingsanalyse', 'erholung', 'leistung');
|
|
||||||
UPDATE ai_prompts SET category = 'schlaf' WHERE slug LIKE '%schlaf%';
|
|
||||||
UPDATE ai_prompts SET category = 'vitalwerte' WHERE slug IN ('vitalwerte', 'herzfrequenz', 'ruhepuls', 'hrv');
|
|
||||||
UPDATE ai_prompts SET category = 'ziele' WHERE slug LIKE '%ziel%' OR slug LIKE '%goal%';
|
|
||||||
|
|
||||||
-- Pipeline prompts remain 'ganzheitlich' (default)
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
-- Migration 018: Add display_name to ai_prompts for user-facing labels
|
|
||||||
|
|
||||||
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS display_name VARCHAR(100);
|
|
||||||
|
|
||||||
-- Migrate existing prompts from hardcoded SLUG_LABELS
|
|
||||||
UPDATE ai_prompts SET display_name = '🔍 Gesamtanalyse' WHERE slug = 'gesamt' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🫧 Körperkomposition' WHERE slug = 'koerper' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🍽️ Ernährung' WHERE slug = 'ernaehrung' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🏋️ Aktivität' WHERE slug = 'aktivitaet' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '❤️ Gesundheitsindikatoren' WHERE slug = 'gesundheit' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🎯 Zielfortschritt' WHERE slug = 'ziele' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🔬 Mehrstufige Gesamtanalyse' WHERE slug = 'pipeline' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Körper-Analyse (JSON)' WHERE slug = 'pipeline_body' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Ernährungs-Analyse (JSON)' WHERE slug = 'pipeline_nutrition' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Aktivitäts-Analyse (JSON)' WHERE slug = 'pipeline_activity' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Synthese' WHERE slug = 'pipeline_synthesis' AND display_name IS NULL;
|
|
||||||
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Zielabgleich' WHERE slug = 'pipeline_goals' AND display_name IS NULL;
|
|
||||||
|
|
||||||
-- Fallback: use name as display_name if still NULL
|
|
||||||
UPDATE ai_prompts SET display_name = name WHERE display_name IS NULL;
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
-- Migration 019: Pipeline-System - Konfigurierbare mehrstufige Analysen
|
|
||||||
-- Ermöglicht Admin-Verwaltung von Pipeline-Konfigurationen (Issue #28)
|
|
||||||
-- Created: 2026-03-25
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 1. Erweitere ai_prompts für Reset-Feature
|
|
||||||
-- ========================================
|
|
||||||
ALTER TABLE ai_prompts
|
|
||||||
ADD COLUMN IF NOT EXISTS is_system_default BOOLEAN DEFAULT FALSE,
|
|
||||||
ADD COLUMN IF NOT EXISTS default_template TEXT;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN ai_prompts.is_system_default IS 'true = System-Prompt mit Reset-Funktion';
|
|
||||||
COMMENT ON COLUMN ai_prompts.default_template IS 'Original-Template für Reset-to-Default';
|
|
||||||
|
|
||||||
-- Markiere bestehende Pipeline-Prompts als System-Defaults
|
|
||||||
UPDATE ai_prompts
|
|
||||||
SET
|
|
||||||
is_system_default = true,
|
|
||||||
default_template = template
|
|
||||||
WHERE slug LIKE 'pipeline_%';
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 2. Create pipeline_configs table
|
|
||||||
-- ========================================
|
|
||||||
CREATE TABLE IF NOT EXISTS pipeline_configs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
description TEXT,
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Module configuration: which data sources to include
|
|
||||||
modules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
-- Example: {"körper": true, "ernährung": true, "training": true, "schlaf": false}
|
|
||||||
|
|
||||||
-- Timeframes per module (days)
|
|
||||||
timeframes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
-- Example: {"körper": 30, "ernährung": 30, "training": 14}
|
|
||||||
|
|
||||||
-- Stage 1 prompts (parallel execution)
|
|
||||||
stage1_prompts TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
|
||||||
-- Example: ARRAY['pipeline_body', 'pipeline_nutrition', 'pipeline_activity']
|
|
||||||
|
|
||||||
-- Stage 2 prompt (synthesis)
|
|
||||||
stage2_prompt VARCHAR(100) NOT NULL,
|
|
||||||
-- Example: 'pipeline_synthesis'
|
|
||||||
|
|
||||||
-- Stage 3 prompt (optional, e.g., goals)
|
|
||||||
stage3_prompt VARCHAR(100),
|
|
||||||
-- Example: 'pipeline_goals'
|
|
||||||
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 3. Create indexes
|
|
||||||
-- ========================================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pipeline_configs_default ON pipeline_configs(is_default) WHERE is_default = true;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pipeline_configs_active ON pipeline_configs(active);
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 4. Seed: Standard-Pipeline "Alltags-Check"
|
|
||||||
-- ========================================
|
|
||||||
INSERT INTO pipeline_configs (
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
is_default,
|
|
||||||
modules,
|
|
||||||
timeframes,
|
|
||||||
stage1_prompts,
|
|
||||||
stage2_prompt,
|
|
||||||
stage3_prompt
|
|
||||||
) VALUES (
|
|
||||||
'Alltags-Check',
|
|
||||||
'Standard-Analyse: Körper, Ernährung, Training über die letzten 2-4 Wochen',
|
|
||||||
true,
|
|
||||||
'{"körper": true, "ernährung": true, "training": true, "schlaf": false, "vitalwerte": false, "mentales": false, "ziele": false}'::jsonb,
|
|
||||||
'{"körper": 30, "ernährung": 30, "training": 14}'::jsonb,
|
|
||||||
ARRAY['pipeline_body', 'pipeline_nutrition', 'pipeline_activity'],
|
|
||||||
'pipeline_synthesis',
|
|
||||||
'pipeline_goals'
|
|
||||||
) ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 5. Seed: Erweiterte Pipelines (optional)
|
|
||||||
-- ========================================
|
|
||||||
|
|
||||||
-- Schlaf-Fokus Pipeline (wenn Schlaf-Prompts existieren)
|
|
||||||
INSERT INTO pipeline_configs (
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
is_default,
|
|
||||||
modules,
|
|
||||||
timeframes,
|
|
||||||
stage1_prompts,
|
|
||||||
stage2_prompt,
|
|
||||||
stage3_prompt
|
|
||||||
) VALUES (
|
|
||||||
'Schlaf & Erholung',
|
|
||||||
'Analyse von Schlaf, Vitalwerten und Erholungsstatus',
|
|
||||||
false,
|
|
||||||
'{"schlaf": true, "vitalwerte": true, "training": true, "körper": false, "ernährung": false, "mentales": false, "ziele": false}'::jsonb,
|
|
||||||
'{"schlaf": 14, "vitalwerte": 7, "training": 14}'::jsonb,
|
|
||||||
ARRAY['pipeline_sleep', 'pipeline_vitals', 'pipeline_activity'],
|
|
||||||
'pipeline_synthesis',
|
|
||||||
NULL
|
|
||||||
) ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Wettkampf-Analyse (langfristiger Trend)
|
|
||||||
INSERT INTO pipeline_configs (
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
is_default,
|
|
||||||
modules,
|
|
||||||
timeframes,
|
|
||||||
stage1_prompts,
|
|
||||||
stage2_prompt,
|
|
||||||
stage3_prompt
|
|
||||||
) VALUES (
|
|
||||||
'Wettkampf-Analyse',
|
|
||||||
'Langfristige Analyse für Wettkampfvorbereitung (90 Tage)',
|
|
||||||
false,
|
|
||||||
'{"körper": true, "training": true, "vitalwerte": true, "ernährung": true, "schlaf": false, "mentales": false, "ziele": true}'::jsonb,
|
|
||||||
'{"körper": 90, "training": 90, "vitalwerte": 30, "ernährung": 60}'::jsonb,
|
|
||||||
ARRAY['pipeline_body', 'pipeline_activity', 'pipeline_vitals', 'pipeline_nutrition'],
|
|
||||||
'pipeline_synthesis',
|
|
||||||
'pipeline_goals'
|
|
||||||
) ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 6. Trigger für updated timestamp
|
|
||||||
-- ========================================
|
|
||||||
DROP TRIGGER IF EXISTS trigger_pipeline_configs_updated ON pipeline_configs;
|
|
||||||
CREATE TRIGGER trigger_pipeline_configs_updated
|
|
||||||
BEFORE UPDATE ON pipeline_configs
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_timestamp();
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 7. Constraints & Validation
|
|
||||||
-- ========================================
|
|
||||||
|
|
||||||
-- Only one default config allowed (enforced via partial unique index)
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_configs_single_default
|
|
||||||
ON pipeline_configs(is_default)
|
|
||||||
WHERE is_default = true;
|
|
||||||
|
|
||||||
-- ========================================
|
|
||||||
-- 8. Comments (Documentation)
|
|
||||||
-- ========================================
|
|
||||||
COMMENT ON TABLE pipeline_configs IS 'v9f Issue #28: Konfigurierbare Pipeline-Analysen. Admins können mehrere Pipeline-Configs erstellen mit unterschiedlichen Modulen und Zeiträumen.';
|
|
||||||
COMMENT ON COLUMN pipeline_configs.modules IS 'JSONB: Welche Module aktiv sind (boolean flags)';
|
|
||||||
COMMENT ON COLUMN pipeline_configs.timeframes IS 'JSONB: Zeiträume pro Modul in Tagen';
|
|
||||||
COMMENT ON COLUMN pipeline_configs.stage1_prompts IS 'Array von slug-Werten für parallele Stage-1-Prompts';
|
|
||||||
COMMENT ON COLUMN pipeline_configs.stage2_prompt IS 'Slug des Synthese-Prompts (kombiniert Stage-1-Ergebnisse)';
|
|
||||||
COMMENT ON COLUMN pipeline_configs.stage3_prompt IS 'Optionaler Slug für Stage-3-Prompt (z.B. Zielabgleich)';
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
-- Migration 020: Unified Prompt System (Issue #28)
|
|
||||||
-- Consolidate ai_prompts and pipeline_configs into single system
|
|
||||||
-- Type: 'base' (reusable building blocks) or 'pipeline' (workflows)
|
|
||||||
|
|
||||||
-- Step 1: Add new columns to ai_prompts and make template nullable
|
|
||||||
ALTER TABLE ai_prompts
|
|
||||||
ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'pipeline',
|
|
||||||
ADD COLUMN IF NOT EXISTS stages JSONB,
|
|
||||||
ADD COLUMN IF NOT EXISTS output_format VARCHAR(10) DEFAULT 'text',
|
|
||||||
ADD COLUMN IF NOT EXISTS output_schema JSONB;
|
|
||||||
|
|
||||||
-- Make template nullable (pipeline-type prompts use stages instead)
|
|
||||||
ALTER TABLE ai_prompts
|
|
||||||
ALTER COLUMN template DROP NOT NULL;
|
|
||||||
|
|
||||||
-- Step 2: Migrate existing single-prompts to 1-stage pipeline format
|
|
||||||
-- All existing prompts become single-stage pipelines with inline source
|
|
||||||
UPDATE ai_prompts
|
|
||||||
SET
|
|
||||||
type = 'pipeline',
|
|
||||||
stages = jsonb_build_array(
|
|
||||||
jsonb_build_object(
|
|
||||||
'stage', 1,
|
|
||||||
'prompts', jsonb_build_array(
|
|
||||||
jsonb_build_object(
|
|
||||||
'source', 'inline',
|
|
||||||
'template', template,
|
|
||||||
'output_key', REPLACE(slug, 'pipeline_', ''),
|
|
||||||
'output_format', 'text'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
output_format = 'text'
|
|
||||||
WHERE stages IS NULL;
|
|
||||||
|
|
||||||
-- Step 3: Migrate pipeline_configs into ai_prompts as multi-stage pipelines
|
|
||||||
-- Each pipeline_config becomes a pipeline-type prompt with multiple stages
|
|
||||||
INSERT INTO ai_prompts (
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
type,
|
|
||||||
stages,
|
|
||||||
output_format,
|
|
||||||
active,
|
|
||||||
is_system_default,
|
|
||||||
category
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
'pipeline_config_' || LOWER(REPLACE(pc.name, ' ', '_')) || '_' || SUBSTRING(pc.id::TEXT FROM 1 FOR 8) as slug,
|
|
||||||
pc.name,
|
|
||||||
pc.description,
|
|
||||||
'pipeline' as type,
|
|
||||||
-- Build stages JSONB: combine stage1_prompts, stage2_prompt, stage3_prompt
|
|
||||||
(
|
|
||||||
-- Stage 1: Convert array to prompts
|
|
||||||
SELECT jsonb_agg(stage_obj ORDER BY stage_num)
|
|
||||||
FROM (
|
|
||||||
SELECT 1 as stage_num,
|
|
||||||
jsonb_build_object(
|
|
||||||
'stage', 1,
|
|
||||||
'prompts', (
|
|
||||||
SELECT jsonb_agg(
|
|
||||||
jsonb_build_object(
|
|
||||||
'source', 'reference',
|
|
||||||
'slug', s1.slug_val,
|
|
||||||
'output_key', REPLACE(s1.slug_val, 'pipeline_', 'stage1_'),
|
|
||||||
'output_format', 'json'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
FROM UNNEST(pc.stage1_prompts) AS s1(slug_val)
|
|
||||||
)
|
|
||||||
) as stage_obj
|
|
||||||
WHERE array_length(pc.stage1_prompts, 1) > 0
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT 2 as stage_num,
|
|
||||||
jsonb_build_object(
|
|
||||||
'stage', 2,
|
|
||||||
'prompts', jsonb_build_array(
|
|
||||||
jsonb_build_object(
|
|
||||||
'source', 'reference',
|
|
||||||
'slug', pc.stage2_prompt,
|
|
||||||
'output_key', 'synthesis',
|
|
||||||
'output_format', 'text'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) as stage_obj
|
|
||||||
WHERE pc.stage2_prompt IS NOT NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT 3 as stage_num,
|
|
||||||
jsonb_build_object(
|
|
||||||
'stage', 3,
|
|
||||||
'prompts', jsonb_build_array(
|
|
||||||
jsonb_build_object(
|
|
||||||
'source', 'reference',
|
|
||||||
'slug', pc.stage3_prompt,
|
|
||||||
'output_key', 'goals',
|
|
||||||
'output_format', 'text'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) as stage_obj
|
|
||||||
WHERE pc.stage3_prompt IS NOT NULL
|
|
||||||
) all_stages
|
|
||||||
) as stages,
|
|
||||||
'text' as output_format,
|
|
||||||
pc.active,
|
|
||||||
pc.is_default as is_system_default,
|
|
||||||
'pipeline' as category
|
|
||||||
FROM pipeline_configs pc;
|
|
||||||
|
|
||||||
-- Step 4: Add indices for performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_type ON ai_prompts(type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_stages ON ai_prompts USING GIN (stages);
|
|
||||||
|
|
||||||
-- Step 5: Add comment explaining stages structure
|
|
||||||
COMMENT ON COLUMN ai_prompts.stages IS 'JSONB array of stages, each with prompts array. Structure: [{"stage":1,"prompts":[{"source":"reference|inline","slug":"...","template":"...","output_key":"key","output_format":"text|json"}]}]';
|
|
||||||
|
|
||||||
-- Step 6: Backup pipeline_configs before eventual deletion
|
|
||||||
CREATE TABLE IF NOT EXISTS pipeline_configs_backup_pre_020 AS
|
|
||||||
SELECT * FROM pipeline_configs;
|
|
||||||
|
|
||||||
-- Note: We keep pipeline_configs table for now during transition period
|
|
||||||
-- It can be dropped in a later migration once all code is migrated
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
-- Migration 021: Add metadata column to ai_insights for storing debug info
|
|
||||||
-- Date: 2026-03-26
|
|
||||||
-- Purpose: Store resolved placeholder values with descriptions for transparency
|
|
||||||
|
|
||||||
ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN ai_insights.metadata IS 'Debug info: resolved placeholders, descriptions, etc.';
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
-- Migration 022: Goal System (Strategic + Tactical)
|
|
||||||
-- Date: 2026-03-26
|
|
||||||
-- Purpose: Two-level goal architecture for AI-driven coaching
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- STRATEGIC LAYER: Goal Modes
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Add goal_mode to profiles (strategic training direction)
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS goal_mode VARCHAR(50) DEFAULT 'health';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN profiles.goal_mode IS
|
|
||||||
'Strategic goal mode: weight_loss, strength, endurance, recomposition, health.
|
|
||||||
Determines score weights and interpretation context for all analyses.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- TACTICAL LAYER: Concrete Goal Targets
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS goals (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Goal Classification
|
|
||||||
goal_type VARCHAR(50) NOT NULL, -- weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
|
|
||||||
is_primary BOOLEAN DEFAULT false,
|
|
||||||
status VARCHAR(20) DEFAULT 'active', -- draft, active, reached, abandoned, expired
|
|
||||||
|
|
||||||
-- Target Values
|
|
||||||
target_value DECIMAL(10,2),
|
|
||||||
current_value DECIMAL(10,2),
|
|
||||||
start_value DECIMAL(10,2),
|
|
||||||
unit VARCHAR(20), -- kg, %, ml/kg/min, bpm, mmHg, cm, reps
|
|
||||||
|
|
||||||
-- Timeline
|
|
||||||
start_date DATE DEFAULT CURRENT_DATE,
|
|
||||||
target_date DATE,
|
|
||||||
reached_date DATE,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
name VARCHAR(100), -- e.g., "Sommerfigur 2026"
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Progress Tracking
|
|
||||||
progress_pct DECIMAL(5,2), -- Auto-calculated: (current - start) / (target - start) * 100
|
|
||||||
projection_date DATE, -- Prognose wann Ziel erreicht wird
|
|
||||||
on_track BOOLEAN, -- true wenn Prognose <= target_date
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_goals_profile ON goals(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_goals_status ON goals(profile_id, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_goals_primary ON goals(profile_id, is_primary) WHERE is_primary = true;
|
|
||||||
|
|
||||||
COMMENT ON TABLE goals IS 'Concrete user goals (tactical targets)';
|
|
||||||
COMMENT ON COLUMN goals.goal_type IS 'Type of goal: weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr';
|
|
||||||
COMMENT ON COLUMN goals.is_primary IS 'Primary goal gets highest priority in scoring and charts';
|
|
||||||
COMMENT ON COLUMN goals.status IS 'draft = not yet started, active = in progress, reached = successfully completed, abandoned = given up, expired = deadline passed';
|
|
||||||
COMMENT ON COLUMN goals.progress_pct IS 'Percentage progress: (current_value - start_value) / (target_value - start_value) * 100';
|
|
||||||
COMMENT ON COLUMN goals.projection_date IS 'Projected date when goal will be reached based on current trend';
|
|
||||||
COMMENT ON COLUMN goals.on_track IS 'true if projection_date <= target_date (goal reachable on time)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- TRAINING PHASES (Auto-Detection)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS training_phases (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Phase Classification
|
|
||||||
phase_type VARCHAR(50) NOT NULL, -- calorie_deficit, calorie_surplus, deload, maintenance, periodization
|
|
||||||
detected_automatically BOOLEAN DEFAULT false,
|
|
||||||
confidence_score DECIMAL(3,2), -- 0.00 - 1.00 (Wie sicher ist die Erkennung?)
|
|
||||||
status VARCHAR(20) DEFAULT 'suggested', -- suggested, accepted, active, completed, rejected
|
|
||||||
|
|
||||||
-- Timeframe
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE,
|
|
||||||
duration_days INT,
|
|
||||||
|
|
||||||
-- Detection Criteria (JSONB für Flexibilität)
|
|
||||||
detection_params JSONB, -- { "avg_calories": 1800, "weight_trend": -0.3, ... }
|
|
||||||
|
|
||||||
-- User Notes
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_training_phases_profile ON training_phases(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_training_phases_status ON training_phases(profile_id, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_training_phases_dates ON training_phases(profile_id, start_date, end_date);
|
|
||||||
|
|
||||||
COMMENT ON TABLE training_phases IS 'Training phases detected from data patterns or manually defined';
|
|
||||||
COMMENT ON COLUMN training_phases.phase_type IS 'calorie_deficit, calorie_surplus, deload, maintenance, periodization';
|
|
||||||
COMMENT ON COLUMN training_phases.detected_automatically IS 'true if AI detected this phase from data patterns';
|
|
||||||
COMMENT ON COLUMN training_phases.confidence_score IS 'AI confidence in detection (0.0 - 1.0)';
|
|
||||||
COMMENT ON COLUMN training_phases.status IS 'suggested = AI proposed, accepted = user confirmed, active = currently running, completed = finished, rejected = user dismissed';
|
|
||||||
COMMENT ON COLUMN training_phases.detection_params IS 'JSON with detection criteria: avg_calories, weight_trend, activity_volume, etc.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- FITNESS TESTS (Standardized Performance Tests)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fitness_tests (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Test Type
|
|
||||||
test_type VARCHAR(50) NOT NULL, -- cooper_12min, step_test, pushups_max, plank_max, flexibility_sit_reach, vo2max_est, strength_1rm_squat, strength_1rm_bench
|
|
||||||
result_value DECIMAL(10,2) NOT NULL,
|
|
||||||
result_unit VARCHAR(20) NOT NULL, -- meters, bpm, reps, seconds, cm, ml/kg/min, kg
|
|
||||||
|
|
||||||
-- Test Metadata
|
|
||||||
test_date DATE NOT NULL,
|
|
||||||
test_conditions TEXT, -- Optional: Notizen zu Bedingungen
|
|
||||||
norm_category VARCHAR(30), -- sehr gut, gut, durchschnitt, unterdurchschnitt, schlecht
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fitness_tests_profile ON fitness_tests(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fitness_tests_type ON fitness_tests(profile_id, test_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fitness_tests_date ON fitness_tests(profile_id, test_date);
|
|
||||||
|
|
||||||
COMMENT ON TABLE fitness_tests IS 'Standardized fitness tests (Cooper, step test, strength tests, etc.)';
|
|
||||||
COMMENT ON COLUMN fitness_tests.test_type IS 'cooper_12min, step_test, pushups_max, plank_max, flexibility_sit_reach, vo2max_est, strength_1rm_squat, strength_1rm_bench';
|
|
||||||
COMMENT ON COLUMN fitness_tests.norm_category IS 'Performance category based on age/gender norms';
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
-- Migration 024: Goal Type Registry (Flexible Goal System)
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Enable dynamic goal types without code changes
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Goal Type Definitions
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS goal_type_definitions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
-- Unique identifier (used in code)
|
|
||||||
type_key VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
|
|
||||||
-- Display metadata
|
|
||||||
label_de VARCHAR(100) NOT NULL,
|
|
||||||
label_en VARCHAR(100),
|
|
||||||
unit VARCHAR(20) NOT NULL,
|
|
||||||
icon VARCHAR(10),
|
|
||||||
category VARCHAR(50), -- body, mind, activity, nutrition, recovery, custom
|
|
||||||
|
|
||||||
-- Data source configuration
|
|
||||||
source_table VARCHAR(50), -- Which table to query
|
|
||||||
source_column VARCHAR(50), -- Which column to fetch
|
|
||||||
aggregation_method VARCHAR(20), -- How to aggregate: latest, avg_7d, avg_30d, sum_30d, count_7d, count_30d, min_30d, max_30d
|
|
||||||
|
|
||||||
-- Complex calculations (optional)
|
|
||||||
-- For types like lean_mass that need custom logic
|
|
||||||
-- JSON format: {"type": "formula", "dependencies": ["weight", "body_fat"], "expression": "..."}
|
|
||||||
calculation_formula TEXT,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
is_system BOOLEAN DEFAULT false, -- System types cannot be deleted
|
|
||||||
|
|
||||||
-- Audit
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_goal_type_definitions_active ON goal_type_definitions(is_active) WHERE is_active = true;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_goal_type_definitions_category ON goal_type_definitions(category);
|
|
||||||
|
|
||||||
COMMENT ON TABLE goal_type_definitions IS 'Registry of available goal types - allows dynamic goal creation without code changes';
|
|
||||||
COMMENT ON COLUMN goal_type_definitions.type_key IS 'Unique key used in code (e.g., weight, meditation_minutes)';
|
|
||||||
COMMENT ON COLUMN goal_type_definitions.aggregation_method IS 'latest = most recent value, avg_7d = 7-day average, count_7d = count in last 7 days, etc.';
|
|
||||||
COMMENT ON COLUMN goal_type_definitions.calculation_formula IS 'JSON for complex calculations like lean_mass = weight - (weight * bf_pct / 100)';
|
|
||||||
COMMENT ON COLUMN goal_type_definitions.is_system IS 'System types are protected from deletion (core functionality)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Seed Data: Migrate existing 8 goal types
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- 1. Weight (simple - latest value)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'weight', 'Gewicht', 'Weight', 'kg', '⚖️', 'body',
|
|
||||||
'weight_log', 'weight', 'latest',
|
|
||||||
'Aktuelles Körpergewicht', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 2. Body Fat (simple - latest value)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'body_fat', 'Körperfett', 'Body Fat', '%', '📊', 'body',
|
|
||||||
'caliper_log', 'body_fat_pct', 'latest',
|
|
||||||
'Körperfettanteil aus Caliper-Messung', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 3. Lean Mass (complex - calculation formula)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
calculation_formula,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'lean_mass', 'Muskelmasse', 'Lean Mass', 'kg', '💪', 'body',
|
|
||||||
'{"type": "lean_mass", "dependencies": ["weight_log.weight", "caliper_log.body_fat_pct"], "formula": "weight - (weight * body_fat_pct / 100)"}',
|
|
||||||
'Fettfreie Körpermasse (berechnet aus Gewicht und Körperfett)', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 4. VO2 Max (simple - latest value)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'vo2max', 'VO2Max', 'VO2Max', 'ml/kg/min', '🫁', 'recovery',
|
|
||||||
'vitals_baseline', 'vo2_max', 'latest',
|
|
||||||
'Maximale Sauerstoffaufnahme (geschätzt oder gemessen)', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 5. Resting Heart Rate (simple - latest value)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'rhr', 'Ruhepuls', 'Resting Heart Rate', 'bpm', '💓', 'recovery',
|
|
||||||
'vitals_baseline', 'resting_hr', 'latest',
|
|
||||||
'Ruhepuls morgens vor dem Aufstehen', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 6. Blood Pressure (placeholder - compound goal for v2.0)
|
|
||||||
-- Currently limited to single value, v2.0 will support systolic/diastolic
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'bp', 'Blutdruck', 'Blood Pressure', 'mmHg', '❤️', 'recovery',
|
|
||||||
'blood_pressure_log', 'systolic', 'latest',
|
|
||||||
'Blutdruck (aktuell nur systolisch, v2.0: beide Werte)', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 7. Strength (placeholder - no data source yet)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
description, is_system, is_active
|
|
||||||
) VALUES (
|
|
||||||
'strength', 'Kraft', 'Strength', 'kg', '🏋️', 'activity',
|
|
||||||
'Maximalkraft (Platzhalter, Datenquelle in v2.0)', true, false
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 8. Flexibility (placeholder - no data source yet)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
description, is_system, is_active
|
|
||||||
) VALUES (
|
|
||||||
'flexibility', 'Beweglichkeit', 'Flexibility', 'cm', '🤸', 'activity',
|
|
||||||
'Beweglichkeit (Platzhalter, Datenquelle in v2.0)', true, false
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Example: Future custom goal types (commented out, for reference)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
/*
|
|
||||||
-- Meditation Minutes (avg last 7 days)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'meditation_minutes', 'Meditation', 'min/Tag', '🧘', 'mind',
|
|
||||||
'meditation_log', 'duration_minutes', 'avg_7d',
|
|
||||||
'Durchschnittliche Meditationsdauer pro Tag (7 Tage)', false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Training Frequency (count last 7 days)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'training_frequency', 'Trainingshäufigkeit', 'x/Woche', '📅', 'activity',
|
|
||||||
'activity_log', 'id', 'count_7d',
|
|
||||||
'Anzahl Trainingseinheiten pro Woche', false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Sleep Quality (avg last 7 days)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'sleep_quality', 'Schlafqualität', '%', '💤', 'recovery',
|
|
||||||
'sleep_log', 'quality_score', 'avg_7d',
|
|
||||||
'Durchschnittliche Schlafqualität (Deep+REM Anteil)', false
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
-- Migration 025: Cleanup goal_type_definitions
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Remove problematic FK columns and ensure seed data
|
|
||||||
|
|
||||||
-- Remove created_by/updated_by columns if they exist
|
|
||||||
-- (May have been created by failed Migration 024)
|
|
||||||
ALTER TABLE goal_type_definitions DROP COLUMN IF EXISTS created_by;
|
|
||||||
ALTER TABLE goal_type_definitions DROP COLUMN IF EXISTS updated_by;
|
|
||||||
|
|
||||||
-- Re-insert seed data (ON CONFLICT ensures idempotency)
|
|
||||||
-- This fixes cases where Migration 024 created table but failed to seed
|
|
||||||
|
|
||||||
-- 1. Weight
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'weight', 'Gewicht', 'Weight', 'kg', '⚖️', 'body',
|
|
||||||
'weight_log', 'weight', 'latest',
|
|
||||||
'Aktuelles Körpergewicht', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 2. Body Fat
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'body_fat', 'Körperfett', 'Body Fat', '%', '📊', 'body',
|
|
||||||
'caliper_log', 'body_fat_pct', 'latest',
|
|
||||||
'Körperfettanteil aus Caliper-Messung', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 3. Lean Mass
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
calculation_formula,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'lean_mass', 'Muskelmasse', 'Lean Mass', 'kg', '💪', 'body',
|
|
||||||
'{"type": "lean_mass", "dependencies": ["weight_log.weight", "caliper_log.body_fat_pct"], "formula": "weight - (weight * body_fat_pct / 100)"}',
|
|
||||||
'Fettfreie Körpermasse (berechnet aus Gewicht und Körperfett)', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 4. VO2 Max
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'vo2max', 'VO2Max', 'VO2Max', 'ml/kg/min', '🫁', 'recovery',
|
|
||||||
'vitals_baseline', 'vo2_max', 'latest',
|
|
||||||
'Maximale Sauerstoffaufnahme (geschätzt oder gemessen)', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 5. Resting Heart Rate
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'rhr', 'Ruhepuls', 'Resting Heart Rate', 'bpm', '💓', 'recovery',
|
|
||||||
'vitals_baseline', 'resting_hr', 'latest',
|
|
||||||
'Ruhepuls morgens vor dem Aufstehen', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 6. Blood Pressure
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'bp', 'Blutdruck', 'Blood Pressure', 'mmHg', '❤️', 'recovery',
|
|
||||||
'blood_pressure_log', 'systolic', 'latest',
|
|
||||||
'Blutdruck (aktuell nur systolisch, v2.0: beide Werte)', true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 7. Strength (inactive placeholder)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
description, is_system, is_active
|
|
||||||
) VALUES (
|
|
||||||
'strength', 'Kraft', 'Strength', 'kg', '🏋️', 'activity',
|
|
||||||
'Maximalkraft (Platzhalter, Datenquelle in v2.0)', true, false
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- 8. Flexibility (inactive placeholder)
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
description, is_system, is_active
|
|
||||||
) VALUES (
|
|
||||||
'flexibility', 'Beweglichkeit', 'Flexibility', 'cm', '🤸', 'activity',
|
|
||||||
'Beweglichkeit (Platzhalter, Datenquelle in v2.0)', true, false
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
-- Migration 026: Goal Type Filters
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Enable filtered counting/aggregation (e.g., count only strength training)
|
|
||||||
|
|
||||||
-- Add filter_conditions column for flexible filtering
|
|
||||||
ALTER TABLE goal_type_definitions
|
|
||||||
ADD COLUMN IF NOT EXISTS filter_conditions JSONB;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN goal_type_definitions.filter_conditions IS
|
|
||||||
'Optional filter conditions as JSON. Example: {"training_type": "strength"} to count only strength training sessions.
|
|
||||||
Supports any column in the source table. Format: {"column_name": "value"} or {"column_name": ["value1", "value2"]} for IN clause.';
|
|
||||||
|
|
||||||
-- Example usage (commented out):
|
|
||||||
/*
|
|
||||||
-- Count only strength training sessions per week
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
filter_conditions,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'strength_frequency', 'Krafttraining Häufigkeit', 'x/Woche', '🏋️', 'activity',
|
|
||||||
'activity_log', 'id', 'count_7d',
|
|
||||||
'{"training_type": "strength"}',
|
|
||||||
'Anzahl Krafttraining-Einheiten pro Woche', false
|
|
||||||
) ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
|
|
||||||
-- Count only cardio sessions per week
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
filter_conditions,
|
|
||||||
description, is_system
|
|
||||||
) VALUES (
|
|
||||||
'cardio_frequency', 'Cardio Häufigkeit', 'x/Woche', '🏃', 'activity',
|
|
||||||
'activity_log', 'id', 'count_7d',
|
|
||||||
'{"training_type": "cardio"}',
|
|
||||||
'Anzahl Cardio-Einheiten pro Woche', false
|
|
||||||
) ON CONFLICT (type_key) DO NOTHING;
|
|
||||||
*/
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
-- Migration 027: Focus Areas System (Goal System v2.0)
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Replace single primary goal with weighted multi-goal system
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Focus Areas Table
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS focus_areas (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Six focus dimensions (percentages, sum = 100)
|
|
||||||
weight_loss_pct INTEGER DEFAULT 0 CHECK (weight_loss_pct >= 0 AND weight_loss_pct <= 100),
|
|
||||||
muscle_gain_pct INTEGER DEFAULT 0 CHECK (muscle_gain_pct >= 0 AND muscle_gain_pct <= 100),
|
|
||||||
strength_pct INTEGER DEFAULT 0 CHECK (strength_pct >= 0 AND strength_pct <= 100),
|
|
||||||
endurance_pct INTEGER DEFAULT 0 CHECK (endurance_pct >= 0 AND endurance_pct <= 100),
|
|
||||||
flexibility_pct INTEGER DEFAULT 0 CHECK (flexibility_pct >= 0 AND flexibility_pct <= 100),
|
|
||||||
health_pct INTEGER DEFAULT 0 CHECK (health_pct >= 0 AND health_pct <= 100),
|
|
||||||
|
|
||||||
-- Status
|
|
||||||
active BOOLEAN DEFAULT true,
|
|
||||||
|
|
||||||
-- Audit
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT sum_equals_100 CHECK (
|
|
||||||
weight_loss_pct + muscle_gain_pct + strength_pct +
|
|
||||||
endurance_pct + flexibility_pct + health_pct = 100
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Only one active focus_areas per profile
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_focus_areas_profile_active
|
|
||||||
ON focus_areas(profile_id) WHERE active = true;
|
|
||||||
|
|
||||||
COMMENT ON TABLE focus_areas IS 'User-defined focus area weights (replaces simple goal_mode). Enables multi-goal prioritization with custom percentages.';
|
|
||||||
COMMENT ON COLUMN focus_areas.weight_loss_pct IS 'Focus on fat loss (0-100%)';
|
|
||||||
COMMENT ON COLUMN focus_areas.muscle_gain_pct IS 'Focus on muscle growth (0-100%)';
|
|
||||||
COMMENT ON COLUMN focus_areas.strength_pct IS 'Focus on strength gains (0-100%)';
|
|
||||||
COMMENT ON COLUMN focus_areas.endurance_pct IS 'Focus on aerobic capacity (0-100%)';
|
|
||||||
COMMENT ON COLUMN focus_areas.flexibility_pct IS 'Focus on mobility/flexibility (0-100%)';
|
|
||||||
COMMENT ON COLUMN focus_areas.health_pct IS 'Focus on general health (0-100%)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Migrate existing goal_mode to focus_areas
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- For each profile with a goal_mode, create initial focus_areas
|
|
||||||
INSERT INTO focus_areas (
|
|
||||||
profile_id,
|
|
||||||
weight_loss_pct, muscle_gain_pct, strength_pct,
|
|
||||||
endurance_pct, flexibility_pct, health_pct
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id AS profile_id,
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'weight_loss' THEN 60
|
|
||||||
WHEN 'recomposition' THEN 30
|
|
||||||
WHEN 'health' THEN 5
|
|
||||||
ELSE 0
|
|
||||||
END AS weight_loss_pct,
|
|
||||||
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'strength' THEN 40 ELSE 0
|
|
||||||
END +
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'recomposition' THEN 30 ELSE 0
|
|
||||||
END AS muscle_gain_pct,
|
|
||||||
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'strength' THEN 50
|
|
||||||
WHEN 'recomposition' THEN 25
|
|
||||||
WHEN 'weight_loss' THEN 10
|
|
||||||
WHEN 'health' THEN 10
|
|
||||||
ELSE 0
|
|
||||||
END AS strength_pct,
|
|
||||||
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'endurance' THEN 70
|
|
||||||
WHEN 'recomposition' THEN 10
|
|
||||||
WHEN 'weight_loss' THEN 20
|
|
||||||
WHEN 'health' THEN 20
|
|
||||||
ELSE 0
|
|
||||||
END AS endurance_pct,
|
|
||||||
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'endurance' THEN 10 ELSE 0
|
|
||||||
END +
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'health' THEN 15 ELSE 0
|
|
||||||
END +
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'recomposition' THEN 5 ELSE 0
|
|
||||||
END +
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'weight_loss' THEN 5 ELSE 0
|
|
||||||
END AS flexibility_pct,
|
|
||||||
|
|
||||||
CASE goal_mode
|
|
||||||
WHEN 'health' THEN 50
|
|
||||||
WHEN 'endurance' THEN 20
|
|
||||||
WHEN 'strength' THEN 10
|
|
||||||
WHEN 'weight_loss' THEN 5
|
|
||||||
ELSE 0
|
|
||||||
END AS health_pct
|
|
||||||
FROM profiles
|
|
||||||
WHERE goal_mode IS NOT NULL
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- For profiles without goal_mode, use balanced health focus
|
|
||||||
INSERT INTO focus_areas (
|
|
||||||
profile_id,
|
|
||||||
weight_loss_pct, muscle_gain_pct, strength_pct,
|
|
||||||
endurance_pct, flexibility_pct, health_pct
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id AS profile_id,
|
|
||||||
0, 0, 10, 20, 15, 55
|
|
||||||
FROM profiles
|
|
||||||
WHERE goal_mode IS NULL
|
|
||||||
AND id NOT IN (SELECT profile_id FROM focus_areas WHERE active = true)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
-- Migration 028: Goal Categories and Priorities
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Multi-dimensional goal priorities (one primary goal per category)
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Add category and priority columns
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
ALTER TABLE goals
|
|
||||||
ADD COLUMN category VARCHAR(50),
|
|
||||||
ADD COLUMN priority INTEGER DEFAULT 2 CHECK (priority >= 1 AND priority <= 3);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN goals.category IS 'Goal category: body, training, nutrition, recovery, health, other';
|
|
||||||
COMMENT ON COLUMN goals.priority IS 'Priority level: 1=high, 2=medium, 3=low';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Migrate existing goals to categories based on goal_type
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
UPDATE goals SET category = CASE
|
|
||||||
-- Body composition goals
|
|
||||||
WHEN goal_type IN ('weight', 'body_fat', 'lean_mass') THEN 'body'
|
|
||||||
|
|
||||||
-- Training goals
|
|
||||||
WHEN goal_type IN ('strength', 'flexibility', 'training_frequency') THEN 'training'
|
|
||||||
|
|
||||||
-- Health/cardio goals
|
|
||||||
WHEN goal_type IN ('vo2max', 'rhr', 'bp', 'hrv') THEN 'health'
|
|
||||||
|
|
||||||
-- Recovery goals
|
|
||||||
WHEN goal_type IN ('sleep_quality', 'sleep_duration', 'rest_days') THEN 'recovery'
|
|
||||||
|
|
||||||
-- Nutrition goals
|
|
||||||
WHEN goal_type IN ('calories', 'protein', 'healthy_eating') THEN 'nutrition'
|
|
||||||
|
|
||||||
-- Default
|
|
||||||
ELSE 'other'
|
|
||||||
END
|
|
||||||
WHERE category IS NULL;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Set priority based on is_primary
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
UPDATE goals SET priority = CASE
|
|
||||||
WHEN is_primary = true THEN 1 -- Primary goals get priority 1
|
|
||||||
ELSE 2 -- Others get priority 2 (medium)
|
|
||||||
END;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Create index for category-based queries
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_goals_category_priority
|
|
||||||
ON goals(profile_id, category, priority);
|
|
||||||
|
|
||||||
COMMENT ON INDEX idx_goals_category_priority IS 'Fast lookup for category-grouped goals sorted by priority';
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
-- Migration 029: Fix Missing Goal Types (flexibility, strength)
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Ensure flexibility and strength goal types are active and properly configured
|
|
||||||
|
|
||||||
-- These types were created earlier but are inactive or misconfigured
|
|
||||||
-- This migration fixes them without breaking if they don't exist
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Upsert flexibility goal type
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
calculation_formula, filter_conditions, description, is_active
|
|
||||||
) VALUES (
|
|
||||||
'flexibility',
|
|
||||||
'Beweglichkeit',
|
|
||||||
'Flexibility',
|
|
||||||
'cm',
|
|
||||||
'🤸',
|
|
||||||
'training',
|
|
||||||
NULL, -- No automatic data source
|
|
||||||
NULL,
|
|
||||||
'latest',
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
'Beweglichkeit und Mobilität - manuelle Erfassung',
|
|
||||||
true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key)
|
|
||||||
DO UPDATE SET
|
|
||||||
label_de = 'Beweglichkeit',
|
|
||||||
label_en = 'Flexibility',
|
|
||||||
unit = 'cm',
|
|
||||||
icon = '🤸',
|
|
||||||
category = 'training',
|
|
||||||
is_active = true,
|
|
||||||
description = 'Beweglichkeit und Mobilität - manuelle Erfassung';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Upsert strength goal type
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
calculation_formula, filter_conditions, description, is_active
|
|
||||||
) VALUES (
|
|
||||||
'strength',
|
|
||||||
'Kraftniveau',
|
|
||||||
'Strength',
|
|
||||||
'Punkte',
|
|
||||||
'💪',
|
|
||||||
'training',
|
|
||||||
NULL, -- No automatic data source
|
|
||||||
NULL,
|
|
||||||
'latest',
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
'Allgemeines Kraftniveau - manuelle Erfassung',
|
|
||||||
true
|
|
||||||
)
|
|
||||||
ON CONFLICT (type_key)
|
|
||||||
DO UPDATE SET
|
|
||||||
label_de = 'Kraftniveau',
|
|
||||||
label_en = 'Strength',
|
|
||||||
unit = 'Punkte',
|
|
||||||
icon = '💪',
|
|
||||||
category = 'training',
|
|
||||||
is_active = true,
|
|
||||||
description = 'Allgemeines Kraftniveau - manuelle Erfassung';
|
|
||||||
|
|
||||||
COMMENT ON TABLE goal_type_definitions IS 'Goal type registry - defines all available goal types (v1.5: DB-driven, flexible system)';
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
-- Migration 030: Goal Progress Log
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Track progress history for all goals (especially custom goals without data source)
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Goal Progress Log Table
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS goal_progress_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Progress data
|
|
||||||
date DATE NOT NULL,
|
|
||||||
value DECIMAL(10,2) NOT NULL,
|
|
||||||
note TEXT,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'automatic', 'import')),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT unique_progress_per_day UNIQUE(goal_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_goal_progress_goal_date ON goal_progress_log(goal_id, date DESC);
|
|
||||||
CREATE INDEX idx_goal_progress_profile ON goal_progress_log(profile_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE goal_progress_log IS 'Progress history for goals - enables manual tracking for custom goals and charts';
|
|
||||||
COMMENT ON COLUMN goal_progress_log.value IS 'Progress value in goal unit (e.g., kg, cm, points)';
|
|
||||||
COMMENT ON COLUMN goal_progress_log.source IS 'manual: user entered, automatic: computed from data source, import: CSV/API';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Function: Update goal current_value from latest progress
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION update_goal_current_value()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Update current_value in goals table with latest progress entry
|
|
||||||
UPDATE goals
|
|
||||||
SET current_value = (
|
|
||||||
SELECT value
|
|
||||||
FROM goal_progress_log
|
|
||||||
WHERE goal_id = NEW.goal_id
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = NEW.goal_id;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Trigger: Auto-update current_value when progress is added/updated
|
|
||||||
CREATE TRIGGER trigger_update_goal_current_value
|
|
||||||
AFTER INSERT OR UPDATE ON goal_progress_log
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_goal_current_value();
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION update_goal_current_value IS 'Auto-update goal.current_value when new progress is logged';
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
-- Migration 031: Focus Area System v2.0
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Dynamic, extensible focus areas with Many-to-Many goal contributions
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Part 1: New Tables
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Focus Area Definitions (dynamic, user-extensible)
|
|
||||||
CREATE TABLE IF NOT EXISTS focus_area_definitions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
key VARCHAR(50) UNIQUE NOT NULL, -- e.g. 'strength', 'aerobic_endurance'
|
|
||||||
name_de VARCHAR(100) NOT NULL,
|
|
||||||
name_en VARCHAR(100),
|
|
||||||
icon VARCHAR(10),
|
|
||||||
description TEXT,
|
|
||||||
category VARCHAR(50), -- 'body_composition', 'training', 'endurance', 'coordination', 'mental', 'recovery', 'health'
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_focus_area_key ON focus_area_definitions(key);
|
|
||||||
CREATE INDEX idx_focus_area_category ON focus_area_definitions(category);
|
|
||||||
|
|
||||||
COMMENT ON TABLE focus_area_definitions IS 'Dynamic focus area registry - defines all available focus dimensions';
|
|
||||||
COMMENT ON COLUMN focus_area_definitions.key IS 'Unique identifier for programmatic access';
|
|
||||||
COMMENT ON COLUMN focus_area_definitions.category IS 'Grouping for UI display';
|
|
||||||
|
|
||||||
-- Many-to-Many: Goals contribute to Focus Areas
|
|
||||||
CREATE TABLE IF NOT EXISTS goal_focus_contributions (
|
|
||||||
goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
|
|
||||||
focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE,
|
|
||||||
contribution_weight DECIMAL(5,2) DEFAULT 100.00 CHECK (contribution_weight >= 0 AND contribution_weight <= 100),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
PRIMARY KEY (goal_id, focus_area_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_gfc_goal ON goal_focus_contributions(goal_id);
|
|
||||||
CREATE INDEX idx_gfc_focus_area ON goal_focus_contributions(focus_area_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE goal_focus_contributions IS 'Maps goals to focus areas with contribution weights (0-100%)';
|
|
||||||
COMMENT ON COLUMN goal_focus_contributions.contribution_weight IS 'How much this goal contributes to the focus area (0-100%)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Part 2: Rename existing focus_areas table
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Old focus_areas table becomes user_focus_preferences
|
|
||||||
ALTER TABLE focus_areas RENAME TO user_focus_preferences;
|
|
||||||
|
|
||||||
-- Add reference to new focus_area_definitions (for future use)
|
|
||||||
ALTER TABLE user_focus_preferences ADD COLUMN IF NOT EXISTS notes TEXT;
|
|
||||||
|
|
||||||
COMMENT ON TABLE user_focus_preferences IS 'User-specific focus area weightings (legacy flat structure + new references)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Part 3: Seed Data - Basis Focus Areas
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
INSERT INTO focus_area_definitions (key, name_de, name_en, icon, category, description) VALUES
|
|
||||||
-- Body Composition
|
|
||||||
('weight_loss', 'Gewichtsverlust', 'Weight Loss', '📉', 'body_composition', 'Körpergewicht reduzieren'),
|
|
||||||
('muscle_gain', 'Muskelaufbau', 'Muscle Gain', '💪', 'body_composition', 'Muskelmasse aufbauen'),
|
|
||||||
('body_recomposition', 'Body Recomposition', 'Body Recomposition', '⚖️', 'body_composition', 'Gleichzeitig Fett abbauen und Muskeln aufbauen'),
|
|
||||||
|
|
||||||
-- Training - Kraft
|
|
||||||
('strength', 'Maximalkraft', 'Strength', '🏋️', 'training', 'Maximale Kraftfähigkeit'),
|
|
||||||
('strength_endurance', 'Kraftausdauer', 'Strength Endurance', '💪🏃', 'training', 'Kraft über längere Zeit aufrechterhalten'),
|
|
||||||
('power', 'Schnellkraft', 'Power', '⚡', 'training', 'Kraft in kurzer Zeit entfalten'),
|
|
||||||
|
|
||||||
-- Training - Beweglichkeit
|
|
||||||
('flexibility', 'Beweglichkeit', 'Flexibility', '🤸', 'training', 'Gelenkigkeit und Bewegungsumfang'),
|
|
||||||
('mobility', 'Mobilität', 'Mobility', '🦴', 'training', 'Aktive Beweglichkeit und Kontrolle'),
|
|
||||||
|
|
||||||
-- Ausdauer
|
|
||||||
('aerobic_endurance', 'Aerobe Ausdauer', 'Aerobic Endurance', '🫁', 'endurance', 'VO2Max, lange moderate Belastung'),
|
|
||||||
('anaerobic_endurance', 'Anaerobe Ausdauer', 'Anaerobic Endurance', '⚡', 'endurance', 'Laktattoleranz, kurze intensive Belastung'),
|
|
||||||
('cardiovascular_health', 'Herz-Kreislauf', 'Cardiovascular Health', '❤️', 'endurance', 'Herzgesundheit und Ausdauer'),
|
|
||||||
|
|
||||||
-- Koordination
|
|
||||||
('balance', 'Gleichgewicht', 'Balance', '⚖️', 'coordination', 'Statisches und dynamisches Gleichgewicht'),
|
|
||||||
('reaction', 'Reaktionsfähigkeit', 'Reaction', '⚡', 'coordination', 'Schnelligkeit der Reaktion auf Reize'),
|
|
||||||
('rhythm', 'Rhythmusgefühl', 'Rhythm', '🎵', 'coordination', 'Zeitliche Abstimmung von Bewegungen'),
|
|
||||||
('coordination', 'Koordination', 'Coordination', '🎯', 'coordination', 'Zusammenspiel verschiedener Bewegungen'),
|
|
||||||
|
|
||||||
-- Mental
|
|
||||||
('stress_resistance', 'Stressresistenz', 'Stress Resistance', '🧘', 'mental', 'Umgang mit mentalem und physischem Stress'),
|
|
||||||
('concentration', 'Konzentration', 'Concentration', '🎯', 'mental', 'Fokussierung und Aufmerksamkeit'),
|
|
||||||
('willpower', 'Willenskraft', 'Willpower', '💎', 'mental', 'Durchhaltevermögen und Selbstdisziplin'),
|
|
||||||
('mental_health', 'Mentale Gesundheit', 'Mental Health', '🧠', 'mental', 'Psychisches Wohlbefinden'),
|
|
||||||
|
|
||||||
-- Recovery
|
|
||||||
('sleep_quality', 'Schlafqualität', 'Sleep Quality', '😴', 'recovery', 'Erholsamer Schlaf'),
|
|
||||||
('regeneration', 'Regeneration', 'Regeneration', '♻️', 'recovery', 'Körperliche Erholung'),
|
|
||||||
('rest', 'Ruhe', 'Rest', '🛌', 'recovery', 'Aktive und passive Erholung'),
|
|
||||||
|
|
||||||
-- Health
|
|
||||||
('metabolic_health', 'Stoffwechselgesundheit', 'Metabolic Health', '🔥', 'health', 'Blutzucker, Insulin, Stoffwechsel'),
|
|
||||||
('blood_pressure', 'Blutdruck', 'Blood Pressure', '❤️🩹', 'health', 'Gesunder Blutdruck'),
|
|
||||||
('hrv', 'Herzratenvariabilität', 'HRV', '💓', 'health', 'Autonomes Nervensystem'),
|
|
||||||
('general_health', 'Allgemeine Gesundheit', 'General Health', '🏥', 'health', 'Vitale Gesundheit und Wohlbefinden')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Part 4: Auto-Mapping - Bestehende Goals zu Focus Areas
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Helper function to get focus_area_id by key
|
|
||||||
CREATE OR REPLACE FUNCTION get_focus_area_id(area_key VARCHAR)
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN (SELECT id FROM focus_area_definitions WHERE key = area_key LIMIT 1);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Weight goals → weight_loss (100%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, get_focus_area_id('weight_loss'), 100.00
|
|
||||||
FROM goals g
|
|
||||||
WHERE g.goal_type = 'weight'
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Body Fat goals → weight_loss (60%) + body_recomposition (40%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'weight_loss' THEN 60.00
|
|
||||||
WHEN 'body_recomposition' THEN 40.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'body_fat'
|
|
||||||
AND fa.key IN ('weight_loss', 'body_recomposition')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Lean Mass goals → muscle_gain (70%) + body_recomposition (30%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'muscle_gain' THEN 70.00
|
|
||||||
WHEN 'body_recomposition' THEN 30.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'lean_mass'
|
|
||||||
AND fa.key IN ('muscle_gain', 'body_recomposition')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Strength goals → strength (70%) + muscle_gain (30%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'strength' THEN 70.00
|
|
||||||
WHEN 'muscle_gain' THEN 30.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'strength'
|
|
||||||
AND fa.key IN ('strength', 'muscle_gain')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Flexibility goals → flexibility (100%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, get_focus_area_id('flexibility'), 100.00
|
|
||||||
FROM goals g
|
|
||||||
WHERE g.goal_type = 'flexibility'
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- VO2Max goals → aerobic_endurance (80%) + cardiovascular_health (20%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'aerobic_endurance' THEN 80.00
|
|
||||||
WHEN 'cardiovascular_health' THEN 20.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'vo2max'
|
|
||||||
AND fa.key IN ('aerobic_endurance', 'cardiovascular_health')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Resting Heart Rate goals → cardiovascular_health (100%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, get_focus_area_id('cardiovascular_health'), 100.00
|
|
||||||
FROM goals g
|
|
||||||
WHERE g.goal_type = 'rhr'
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Blood Pressure goals → blood_pressure (80%) + cardiovascular_health (20%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'blood_pressure' THEN 80.00
|
|
||||||
WHEN 'cardiovascular_health' THEN 20.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'bp'
|
|
||||||
AND fa.key IN ('blood_pressure', 'cardiovascular_health')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- HRV goals → hrv (70%) + stress_resistance (30%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'hrv' THEN 70.00
|
|
||||||
WHEN 'stress_resistance' THEN 30.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'hrv'
|
|
||||||
AND fa.key IN ('hrv', 'stress_resistance')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Sleep Quality goals → sleep_quality (100%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, get_focus_area_id('sleep_quality'), 100.00
|
|
||||||
FROM goals g
|
|
||||||
WHERE g.goal_type = 'sleep_quality'
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Training Frequency goals → general catch-all (strength + endurance)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'strength' THEN 40.00
|
|
||||||
WHEN 'aerobic_endurance' THEN 40.00
|
|
||||||
WHEN 'general_health' THEN 20.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'training_frequency'
|
|
||||||
AND fa.key IN ('strength', 'aerobic_endurance', 'general_health')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Cleanup helper function
|
|
||||||
DROP FUNCTION IF EXISTS get_focus_area_id(VARCHAR);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Summary
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
COMMENT ON TABLE focus_area_definitions IS
|
|
||||||
'v2.0: Dynamic focus areas - replaces hardcoded 6-dimension system.
|
|
||||||
26 base areas across 7 categories. User-extensible via admin UI.';
|
|
||||||
|
|
||||||
COMMENT ON TABLE goal_focus_contributions IS
|
|
||||||
'Many-to-Many mapping: Goals contribute to multiple focus areas with weights.
|
|
||||||
Auto-mapped from goal_type, editable by user.';
|
|
||||||
|
|
||||||
COMMENT ON TABLE user_focus_preferences IS
|
|
||||||
'Legacy flat structure (weight_loss_pct, muscle_gain_pct, etc.) remains for backward compatibility.
|
|
||||||
Future: Use focus_area_definitions + dynamic preferences.';
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
-- Migration 032: User Focus Area Weights
|
|
||||||
-- Date: 2026-03-27
|
|
||||||
-- Purpose: Allow users to set custom weights for focus areas (dynamic preferences)
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- User Focus Area Weights (many-to-many with weights)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_focus_area_weights (
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE,
|
|
||||||
weight INTEGER NOT NULL DEFAULT 0 CHECK (weight >= 0 AND weight <= 100),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
PRIMARY KEY (profile_id, focus_area_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_user_focus_weights_profile ON user_focus_area_weights(profile_id);
|
|
||||||
CREATE INDEX idx_user_focus_weights_area ON user_focus_area_weights(focus_area_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE user_focus_area_weights IS 'User-specific weights for focus areas (dynamic system)';
|
|
||||||
COMMENT ON COLUMN user_focus_area_weights.weight IS 'Relative weight (0-100) - will be normalized to percentages in UI';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Migrate legacy preferences to dynamic weights
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- For each user with legacy preferences, create weights for the 6 base areas
|
|
||||||
INSERT INTO user_focus_area_weights (profile_id, focus_area_id, weight)
|
|
||||||
SELECT
|
|
||||||
ufp.profile_id,
|
|
||||||
fad.id as focus_area_id,
|
|
||||||
CASE fad.key
|
|
||||||
WHEN 'weight_loss' THEN ufp.weight_loss_pct
|
|
||||||
WHEN 'muscle_gain' THEN ufp.muscle_gain_pct
|
|
||||||
WHEN 'strength' THEN ufp.strength_pct
|
|
||||||
WHEN 'aerobic_endurance' THEN ufp.endurance_pct
|
|
||||||
WHEN 'flexibility' THEN ufp.flexibility_pct
|
|
||||||
WHEN 'general_health' THEN ufp.health_pct
|
|
||||||
ELSE 0
|
|
||||||
END as weight
|
|
||||||
FROM user_focus_preferences ufp
|
|
||||||
CROSS JOIN focus_area_definitions fad
|
|
||||||
WHERE fad.key IN ('weight_loss', 'muscle_gain', 'strength', 'aerobic_endurance', 'flexibility', 'general_health')
|
|
||||||
AND (
|
|
||||||
(fad.key = 'weight_loss' AND ufp.weight_loss_pct > 0) OR
|
|
||||||
(fad.key = 'muscle_gain' AND ufp.muscle_gain_pct > 0) OR
|
|
||||||
(fad.key = 'strength' AND ufp.strength_pct > 0) OR
|
|
||||||
(fad.key = 'aerobic_endurance' AND ufp.endurance_pct > 0) OR
|
|
||||||
(fad.key = 'flexibility' AND ufp.flexibility_pct > 0) OR
|
|
||||||
(fad.key = 'general_health' AND ufp.health_pct > 0)
|
|
||||||
)
|
|
||||||
ON CONFLICT (profile_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
-- Migration 033: Nutrition Focus Areas
|
|
||||||
-- Date: 2026-03-28
|
|
||||||
-- Purpose: Add missing nutrition category to complete focus area coverage
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Part 1: Add Nutrition Focus Areas
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
INSERT INTO focus_area_definitions (key, name_de, name_en, icon, category, description) VALUES
|
|
||||||
-- Nutrition Category
|
|
||||||
('protein_intake', 'Proteinzufuhr', 'Protein Intake', '🥩', 'nutrition', 'Ausreichend Protein für Muskelaufbau/-erhalt'),
|
|
||||||
('calorie_balance', 'Kalorienbilanz', 'Calorie Balance', '⚖️', 'nutrition', 'Energiebilanz passend zum Ziel (Defizit/Überschuss)'),
|
|
||||||
('macro_consistency', 'Makro-Konsistenz', 'Macro Consistency', '📊', 'nutrition', 'Gleichmäßige Makronährstoff-Verteilung'),
|
|
||||||
('meal_timing', 'Mahlzeiten-Timing', 'Meal Timing', '⏰', 'nutrition', 'Regelmäßige Mahlzeiten und optimales Timing'),
|
|
||||||
('hydration', 'Flüssigkeitszufuhr', 'Hydration', '💧', 'nutrition', 'Ausreichende Flüssigkeitsaufnahme')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Part 2: Auto-Mapping for Nutrition-Related Goals
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Helper function to get focus_area_id by key
|
|
||||||
CREATE OR REPLACE FUNCTION get_focus_area_id(area_key VARCHAR)
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN (SELECT id FROM focus_area_definitions WHERE key = area_key LIMIT 1);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Weight Loss goals → calorie_balance (40%) + protein_intake (30%)
|
|
||||||
-- (Already mapped to weight_loss in migration 031, adding nutrition aspects)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'calorie_balance' THEN 40.00
|
|
||||||
WHEN 'protein_intake' THEN 30.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'weight'
|
|
||||||
AND fa.key IN ('calorie_balance', 'protein_intake')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Body Fat goals → calorie_balance (30%) + protein_intake (40%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'calorie_balance' THEN 30.00
|
|
||||||
WHEN 'protein_intake' THEN 40.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'body_fat'
|
|
||||||
AND fa.key IN ('calorie_balance', 'protein_intake')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Lean Mass goals → protein_intake (60%) + calorie_balance (20%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, fa.id,
|
|
||||||
CASE fa.key
|
|
||||||
WHEN 'protein_intake' THEN 60.00
|
|
||||||
WHEN 'calorie_balance' THEN 20.00
|
|
||||||
END
|
|
||||||
FROM goals g
|
|
||||||
CROSS JOIN focus_area_definitions fa
|
|
||||||
WHERE g.goal_type = 'lean_mass'
|
|
||||||
AND fa.key IN ('protein_intake', 'calorie_balance')
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Strength goals → protein_intake (20%)
|
|
||||||
INSERT INTO goal_focus_contributions (goal_id, focus_area_id, contribution_weight)
|
|
||||||
SELECT g.id, get_focus_area_id('protein_intake'), 20.00
|
|
||||||
FROM goals g
|
|
||||||
WHERE g.goal_type = 'strength'
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Cleanup helper function
|
|
||||||
DROP FUNCTION IF EXISTS get_focus_area_id(VARCHAR);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Summary
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
COMMENT ON COLUMN focus_area_definitions.category IS
|
|
||||||
'Categories: body_composition, training, endurance, coordination, mental, recovery, health, nutrition';
|
|
||||||
|
|
||||||
-- Count nutrition focus areas
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
nutrition_count INT;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*) INTO nutrition_count
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE category = 'nutrition';
|
|
||||||
|
|
||||||
RAISE NOTICE 'Migration 033 complete: % nutrition focus areas added', nutrition_count;
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
-- ============================================================================
|
|
||||||
-- Feature Check Script - Diagnose vor/nach Migration
|
|
||||||
-- ============================================================================
|
|
||||||
-- Usage: psql -U mitai_dev -d mitai_dev -f check_features.sql
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
\echo '=== CURRENT FEATURES ==='
|
|
||||||
SELECT id, name, category, limit_type, reset_period, default_limit, active
|
|
||||||
FROM features
|
|
||||||
ORDER BY category, id;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo '=== TIER LIMITS MATRIX ==='
|
|
||||||
SELECT
|
|
||||||
f.id as feature,
|
|
||||||
f.category,
|
|
||||||
MAX(CASE WHEN tl.tier_id = 'free' THEN COALESCE(tl.limit_value::text, '∞') END) as free,
|
|
||||||
MAX(CASE WHEN tl.tier_id = 'basic' THEN COALESCE(tl.limit_value::text, '∞') END) as basic,
|
|
||||||
MAX(CASE WHEN tl.tier_id = 'premium' THEN COALESCE(tl.limit_value::text, '∞') END) as premium,
|
|
||||||
MAX(CASE WHEN tl.tier_id = 'selfhosted' THEN COALESCE(tl.limit_value::text, '∞') END) as selfhosted
|
|
||||||
FROM features f
|
|
||||||
LEFT JOIN tier_limits tl ON f.id = tl.feature_id
|
|
||||||
GROUP BY f.id, f.category
|
|
||||||
ORDER BY f.category, f.id;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo '=== FEATURE COUNT BY CATEGORY ==='
|
|
||||||
SELECT category, COUNT(*) as count
|
|
||||||
FROM features
|
|
||||||
WHERE active = true
|
|
||||||
GROUP BY category
|
|
||||||
ORDER BY category;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo '=== ORPHANED TIER LIMITS (feature not exists) ==='
|
|
||||||
SELECT tl.tier_id, tl.feature_id, tl.limit_value
|
|
||||||
FROM tier_limits tl
|
|
||||||
LEFT JOIN features f ON tl.feature_id = f.id
|
|
||||||
WHERE f.id IS NULL;
|
|
||||||
|
|
||||||
\echo ''
|
|
||||||
\echo '=== USER FEATURE USAGE (current usage tracking) ==='
|
|
||||||
SELECT
|
|
||||||
p.name as user,
|
|
||||||
ufu.feature_id,
|
|
||||||
ufu.usage_count,
|
|
||||||
ufu.reset_at
|
|
||||||
FROM user_feature_usage ufu
|
|
||||||
JOIN profiles p ON ufu.profile_id = p.id
|
|
||||||
ORDER BY p.name, ufu.feature_id;
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
-- ============================================================================
|
|
||||||
-- v9c Cleanup: Feature-Konsolidierung
|
|
||||||
-- ============================================================================
|
|
||||||
-- Created: 2026-03-20
|
|
||||||
-- Purpose: Konsolidiere Export-Features (export_csv/json/zip → data_export)
|
|
||||||
-- und Import-Features (csv_import → data_import)
|
|
||||||
--
|
|
||||||
-- Idempotent: Kann mehrfach ausgeführt werden
|
|
||||||
--
|
|
||||||
-- Lessons Learned:
|
|
||||||
-- "Ein Feature für Export, nicht drei (csv/json/zip)"
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 1. Rename csv_import to data_import
|
|
||||||
-- ============================================================================
|
|
||||||
UPDATE features
|
|
||||||
SET
|
|
||||||
id = 'data_import',
|
|
||||||
name = 'Daten importieren',
|
|
||||||
description = 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import'
|
|
||||||
WHERE id = 'csv_import';
|
|
||||||
|
|
||||||
-- Update tier_limits references
|
|
||||||
UPDATE tier_limits
|
|
||||||
SET feature_id = 'data_import'
|
|
||||||
WHERE feature_id = 'csv_import';
|
|
||||||
|
|
||||||
-- Update user_feature_restrictions references
|
|
||||||
UPDATE user_feature_restrictions
|
|
||||||
SET feature_id = 'data_import'
|
|
||||||
WHERE feature_id = 'csv_import';
|
|
||||||
|
|
||||||
-- Update user_feature_usage references
|
|
||||||
UPDATE user_feature_usage
|
|
||||||
SET feature_id = 'data_import'
|
|
||||||
WHERE feature_id = 'csv_import';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 2. Remove old export_csv/json/zip features
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Remove tier_limits for old features
|
|
||||||
DELETE FROM tier_limits
|
|
||||||
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
|
|
||||||
|
|
||||||
-- Remove user restrictions for old features
|
|
||||||
DELETE FROM user_feature_restrictions
|
|
||||||
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
|
|
||||||
|
|
||||||
-- Remove usage tracking for old features
|
|
||||||
DELETE FROM user_feature_usage
|
|
||||||
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
|
|
||||||
|
|
||||||
-- Remove old feature definitions
|
|
||||||
DELETE FROM features
|
|
||||||
WHERE id IN ('export_csv', 'export_json', 'export_zip');
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 3. Ensure data_export exists and is properly configured
|
|
||||||
-- ============================================================================
|
|
||||||
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
|
|
||||||
VALUES ('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
category = EXCLUDED.category,
|
|
||||||
limit_type = EXCLUDED.limit_type,
|
|
||||||
reset_period = EXCLUDED.reset_period;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 4. Ensure data_import exists and is properly configured
|
|
||||||
-- ============================================================================
|
|
||||||
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
|
|
||||||
VALUES ('data_import', 'Daten importieren', 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import', 'import', 'count', 'monthly', 0, true)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
category = EXCLUDED.category,
|
|
||||||
limit_type = EXCLUDED.limit_type,
|
|
||||||
reset_period = EXCLUDED.reset_period;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 5. Update tier_limits for data_export (consolidate from old features)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- FREE tier: no export
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('free', 'data_export', 0)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- BASIC tier: 5 exports/month
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('basic', 'data_export', 5)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- PREMIUM tier: unlimited
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('premium', 'data_export', NULL)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- SELFHOSTED tier: unlimited
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('selfhosted', 'data_export', NULL)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 6. Update tier_limits for data_import
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- FREE tier: no import
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('free', 'data_import', 0)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- BASIC tier: 3 imports/month
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('basic', 'data_import', 3)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- PREMIUM tier: unlimited
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('premium', 'data_import', NULL)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- SELFHOSTED tier: unlimited
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
|
||||||
VALUES ('selfhosted', 'data_import', NULL)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Cleanup complete
|
|
||||||
-- ============================================================================
|
|
||||||
-- Final feature list:
|
|
||||||
-- Data: weight_entries, circumference_entries, caliper_entries,
|
|
||||||
-- nutrition_entries, activity_entries, photos
|
|
||||||
-- AI: ai_calls, ai_pipeline
|
|
||||||
-- Export/Import: data_export, data_import
|
|
||||||
--
|
|
||||||
-- Total: 10 features (down from 13)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
-- Fix missing features for v9c feature enforcement
|
|
||||||
-- 2026-03-20
|
|
||||||
|
|
||||||
-- Add missing features
|
|
||||||
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES
|
|
||||||
('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true),
|
|
||||||
('csv_import', 'CSV importieren', 'FDDB/Apple Health CSV Import + ZIP Backup Import', 'import', 'count', 'monthly', 0, true)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Add tier limits for new features
|
|
||||||
-- FREE tier
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('free', 'data_export', 0), -- Kein Export
|
|
||||||
('free', 'csv_import', 0) -- Kein Import
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- BASIC tier
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('basic', 'data_export', 5), -- 5 Exporte/Monat
|
|
||||||
('basic', 'csv_import', 3) -- 3 Imports/Monat
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- PREMIUM tier
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('premium', 'data_export', NULL), -- Unbegrenzt
|
|
||||||
('premium', 'csv_import', NULL) -- Unbegrenzt
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- SELFHOSTED tier
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('selfhosted', 'data_export', NULL), -- Unbegrenzt
|
|
||||||
('selfhosted', 'csv_import', NULL) -- Unbegrenzt
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
@ -1,352 +0,0 @@
|
||||||
-- ============================================================================
|
|
||||||
-- Mitai Jinkendo v9c: Subscription & Coupon System Migration
|
|
||||||
-- ============================================================================
|
|
||||||
-- Created: 2026-03-19
|
|
||||||
-- Purpose: Add flexible tier system with Feature-Registry Pattern
|
|
||||||
--
|
|
||||||
-- Tables added:
|
|
||||||
-- 1. app_settings - Global configuration
|
|
||||||
-- 2. tiers - Subscription tiers (simplified)
|
|
||||||
-- 3. features - Feature registry (all limitable features)
|
|
||||||
-- 4. tier_limits - Tier x Feature matrix
|
|
||||||
-- 5. user_feature_restrictions - Individual user overrides
|
|
||||||
-- 6. user_feature_usage - Usage tracking
|
|
||||||
-- 7. coupons - Coupon management
|
|
||||||
-- 8. coupon_redemptions - Redemption history
|
|
||||||
-- 9. access_grants - Time-limited access grants
|
|
||||||
-- 10. user_activity_log - Activity tracking
|
|
||||||
-- 11. user_stats - Aggregated statistics
|
|
||||||
--
|
|
||||||
-- Feature-Registry Pattern:
|
|
||||||
-- Instead of hardcoded columns (max_weight_entries, max_ai_calls),
|
|
||||||
-- all limits are defined in features table and configured via tier_limits.
|
|
||||||
-- This allows adding new limitable features without schema changes.
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 1. app_settings - Global configuration
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 2. tiers - Subscription tiers (simplified)
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS tiers (
|
|
||||||
id TEXT PRIMARY KEY, -- 'free', 'basic', 'premium', 'selfhosted'
|
|
||||||
name TEXT NOT NULL, -- Display name
|
|
||||||
description TEXT, -- Marketing description
|
|
||||||
price_monthly_cents INTEGER, -- NULL for free/selfhosted
|
|
||||||
price_yearly_cents INTEGER, -- NULL for free/selfhosted
|
|
||||||
stripe_price_id_monthly TEXT, -- Stripe Price ID (for v9d)
|
|
||||||
stripe_price_id_yearly TEXT, -- Stripe Price ID (for v9d)
|
|
||||||
active BOOLEAN DEFAULT true, -- Can new users subscribe?
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 3. features - Feature registry (all limitable features)
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS features (
|
|
||||||
id TEXT PRIMARY KEY, -- 'weight_entries', 'ai_calls', 'photos', etc.
|
|
||||||
name TEXT NOT NULL, -- Display name
|
|
||||||
description TEXT, -- What is this feature?
|
|
||||||
category TEXT, -- 'data', 'ai', 'export', 'integration'
|
|
||||||
limit_type TEXT DEFAULT 'count', -- 'count', 'boolean', 'quota'
|
|
||||||
reset_period TEXT DEFAULT 'never', -- 'never', 'monthly', 'daily'
|
|
||||||
default_limit INTEGER, -- Fallback if no tier_limit defined
|
|
||||||
active BOOLEAN DEFAULT true, -- Is this feature currently used?
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 4. tier_limits - Tier x Feature matrix
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS tier_limits (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
tier_id TEXT NOT NULL REFERENCES tiers(id) ON DELETE CASCADE,
|
|
||||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
|
||||||
limit_value INTEGER, -- NULL = unlimited, 0 = disabled
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(tier_id, feature_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 5. user_feature_restrictions - Individual user overrides
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS user_feature_restrictions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
|
||||||
limit_value INTEGER, -- NULL = unlimited, 0 = disabled
|
|
||||||
reason TEXT, -- Why was this override applied?
|
|
||||||
created_by UUID, -- Admin profile_id
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(profile_id, feature_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 6. user_feature_usage - Usage tracking
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS user_feature_usage (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
|
|
||||||
usage_count INTEGER DEFAULT 0,
|
|
||||||
reset_at TIMESTAMP, -- When does this counter reset?
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(profile_id, feature_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 7. coupons - Coupon management
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS coupons (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
code TEXT UNIQUE NOT NULL,
|
|
||||||
type TEXT NOT NULL, -- 'single_use', 'period', 'wellpass'
|
|
||||||
tier_id TEXT REFERENCES tiers(id) ON DELETE SET NULL,
|
|
||||||
duration_days INTEGER, -- For period/wellpass coupons
|
|
||||||
max_redemptions INTEGER, -- NULL = unlimited
|
|
||||||
redemption_count INTEGER DEFAULT 0,
|
|
||||||
valid_from TIMESTAMP,
|
|
||||||
valid_until TIMESTAMP,
|
|
||||||
active BOOLEAN DEFAULT true,
|
|
||||||
created_by UUID, -- Admin profile_id
|
|
||||||
description TEXT, -- Internal note
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 8. coupon_redemptions - Redemption history
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS coupon_redemptions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
redeemed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
access_grant_id UUID, -- FK to access_grants (created as result)
|
|
||||||
UNIQUE(coupon_id, profile_id) -- One redemption per user per coupon
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 9. access_grants - Time-limited access grants
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS access_grants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
tier_id TEXT NOT NULL REFERENCES tiers(id) ON DELETE CASCADE,
|
|
||||||
granted_by TEXT, -- 'coupon', 'admin', 'trial', 'subscription'
|
|
||||||
coupon_id UUID REFERENCES coupons(id) ON DELETE SET NULL,
|
|
||||||
valid_from TIMESTAMP NOT NULL,
|
|
||||||
valid_until TIMESTAMP NOT NULL,
|
|
||||||
is_active BOOLEAN DEFAULT true, -- Can be paused by Wellpass logic
|
|
||||||
paused_by UUID, -- access_grant.id that paused this
|
|
||||||
paused_at TIMESTAMP, -- When was it paused?
|
|
||||||
remaining_days INTEGER, -- Days left when paused (for resume)
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 10. user_activity_log - Activity tracking
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS user_activity_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
action TEXT NOT NULL, -- 'login', 'logout', 'coupon_redeemed', 'tier_changed'
|
|
||||||
details JSONB, -- Flexible metadata
|
|
||||||
ip_address TEXT,
|
|
||||||
user_agent TEXT,
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_log_profile ON user_activity_log(profile_id, created DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_log_action ON user_activity_log(action, created DESC);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 11. user_stats - Aggregated statistics
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS user_stats (
|
|
||||||
profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
last_login TIMESTAMP,
|
|
||||||
login_count INTEGER DEFAULT 0,
|
|
||||||
weight_entries_count INTEGER DEFAULT 0,
|
|
||||||
ai_calls_count INTEGER DEFAULT 0,
|
|
||||||
photos_count INTEGER DEFAULT 0,
|
|
||||||
total_data_points INTEGER DEFAULT 0,
|
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Extend profiles table with subscription fields
|
|
||||||
-- ============================================================================
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS tier TEXT DEFAULT 'free';
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP;
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false;
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verify_token TEXT;
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS invited_by UUID REFERENCES profiles(id) ON DELETE SET NULL;
|
|
||||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS invitation_token TEXT;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Insert initial data
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- App settings
|
|
||||||
INSERT INTO app_settings (key, value, description) VALUES
|
|
||||||
('trial_duration_days', '14', 'Default trial duration for new registrations'),
|
|
||||||
('post_trial_tier', 'free', 'Tier after trial expires (free/disabled)'),
|
|
||||||
('require_email_verification', 'true', 'Require email verification before activation'),
|
|
||||||
('self_registration_enabled', 'true', 'Allow self-registration')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
|
||||||
|
|
||||||
-- Tiers
|
|
||||||
INSERT INTO tiers (id, name, description, price_monthly_cents, price_yearly_cents, active, sort_order) VALUES
|
|
||||||
('free', 'Free', 'Eingeschränkte Basis-Funktionen', NULL, NULL, true, 1),
|
|
||||||
('basic', 'Basic', 'Kernfunktionen ohne KI', 499, 4990, true, 2),
|
|
||||||
('premium', 'Premium', 'Alle Features inkl. KI und Connectoren', 999, 9990, true, 3),
|
|
||||||
('selfhosted', 'Self-Hosted', 'Unbegrenzt (für Heimserver)', NULL, NULL, false, 4)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Features (11 initial features)
|
|
||||||
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES
|
|
||||||
('weight_entries', 'Gewichtseinträge', 'Anzahl Gewichtsmessungen', 'data', 'count', 'never', NULL, true),
|
|
||||||
('circumference_entries', 'Umfangs-Einträge', 'Anzahl Umfangsmessungen', 'data', 'count', 'never', NULL, true),
|
|
||||||
('caliper_entries', 'Caliper-Einträge', 'Anzahl Hautfaltenmessungen', 'data', 'count', 'never', NULL, true),
|
|
||||||
('nutrition_entries', 'Ernährungs-Einträge', 'Anzahl Ernährungslogs', 'data', 'count', 'never', NULL, true),
|
|
||||||
('activity_entries', 'Aktivitäts-Einträge', 'Anzahl Trainings/Aktivitäten', 'data', 'count', 'never', NULL, true),
|
|
||||||
('photos', 'Progress-Fotos', 'Anzahl hochgeladene Fotos', 'data', 'count', 'never', NULL, true),
|
|
||||||
('ai_calls', 'KI-Analysen', 'KI-Auswertungen pro Monat', 'ai', 'count', 'monthly', 0, true),
|
|
||||||
('ai_pipeline', 'KI-Pipeline', 'Vollständige Pipeline-Analyse', 'ai', 'boolean', 'never', 0, true),
|
|
||||||
('export_csv', 'CSV-Export', 'Daten als CSV exportieren', 'export', 'boolean', 'never', 0, true),
|
|
||||||
('export_json', 'JSON-Export', 'Daten als JSON exportieren', 'export', 'boolean', 'never', 0, true),
|
|
||||||
('export_zip', 'ZIP-Export', 'Vollständiger Backup-Export', 'export', 'boolean', 'never', 0, true)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Tier x Feature Matrix (tier_limits)
|
|
||||||
-- Format: (tier, feature, limit) - NULL = unlimited, 0 = disabled
|
|
||||||
|
|
||||||
-- FREE tier (sehr eingeschränkt)
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('free', 'weight_entries', 30),
|
|
||||||
('free', 'circumference_entries', 10),
|
|
||||||
('free', 'caliper_entries', 10),
|
|
||||||
('free', 'nutrition_entries', 30),
|
|
||||||
('free', 'activity_entries', 30),
|
|
||||||
('free', 'photos', 5),
|
|
||||||
('free', 'ai_calls', 0), -- Keine KI
|
|
||||||
('free', 'ai_pipeline', 0), -- Keine Pipeline
|
|
||||||
('free', 'export_csv', 0), -- Kein Export
|
|
||||||
('free', 'export_json', 0),
|
|
||||||
('free', 'export_zip', 0)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- BASIC tier (Kernfunktionen)
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('basic', 'weight_entries', NULL), -- Unbegrenzt
|
|
||||||
('basic', 'circumference_entries', NULL),
|
|
||||||
('basic', 'caliper_entries', NULL),
|
|
||||||
('basic', 'nutrition_entries', NULL),
|
|
||||||
('basic', 'activity_entries', NULL),
|
|
||||||
('basic', 'photos', 50),
|
|
||||||
('basic', 'ai_calls', 3), -- 3 KI-Calls/Monat
|
|
||||||
('basic', 'ai_pipeline', 0), -- Keine Pipeline
|
|
||||||
('basic', 'export_csv', 1), -- Export erlaubt
|
|
||||||
('basic', 'export_json', 1),
|
|
||||||
('basic', 'export_zip', 1)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- PREMIUM tier (alles unbegrenzt)
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('premium', 'weight_entries', NULL),
|
|
||||||
('premium', 'circumference_entries', NULL),
|
|
||||||
('premium', 'caliper_entries', NULL),
|
|
||||||
('premium', 'nutrition_entries', NULL),
|
|
||||||
('premium', 'activity_entries', NULL),
|
|
||||||
('premium', 'photos', NULL),
|
|
||||||
('premium', 'ai_calls', NULL), -- Unbegrenzt KI
|
|
||||||
('premium', 'ai_pipeline', 1), -- Pipeline erlaubt
|
|
||||||
('premium', 'export_csv', 1),
|
|
||||||
('premium', 'export_json', 1),
|
|
||||||
('premium', 'export_zip', 1)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- SELFHOSTED tier (alles unbegrenzt)
|
|
||||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
|
||||||
('selfhosted', 'weight_entries', NULL),
|
|
||||||
('selfhosted', 'circumference_entries', NULL),
|
|
||||||
('selfhosted', 'caliper_entries', NULL),
|
|
||||||
('selfhosted', 'nutrition_entries', NULL),
|
|
||||||
('selfhosted', 'activity_entries', NULL),
|
|
||||||
('selfhosted', 'photos', NULL),
|
|
||||||
('selfhosted', 'ai_calls', NULL),
|
|
||||||
('selfhosted', 'ai_pipeline', 1),
|
|
||||||
('selfhosted', 'export_csv', 1),
|
|
||||||
('selfhosted', 'export_json', 1),
|
|
||||||
('selfhosted', 'export_zip', 1)
|
|
||||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Migrate existing profiles
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Lars' Profile → selfhosted tier with email verified
|
|
||||||
UPDATE profiles
|
|
||||||
SET
|
|
||||||
tier = 'selfhosted',
|
|
||||||
email_verified = true
|
|
||||||
WHERE
|
|
||||||
email = 'lars@stommer.com'
|
|
||||||
OR role = 'admin';
|
|
||||||
|
|
||||||
-- Other existing profiles → free tier, unverified
|
|
||||||
UPDATE profiles
|
|
||||||
SET
|
|
||||||
tier = 'free',
|
|
||||||
email_verified = false
|
|
||||||
WHERE
|
|
||||||
tier IS NULL
|
|
||||||
OR tier = '';
|
|
||||||
|
|
||||||
-- Initialize user_stats for existing profiles
|
|
||||||
INSERT INTO user_stats (profile_id, weight_entries_count, photos_count)
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
(SELECT COUNT(*) FROM weight_log WHERE profile_id = p.id),
|
|
||||||
(SELECT COUNT(*) FROM photos WHERE profile_id = p.id)
|
|
||||||
FROM profiles p
|
|
||||||
ON CONFLICT (profile_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Create indexes for performance
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tier_limits_tier ON tier_limits(tier_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tier_limits_feature ON tier_limits(feature_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_restrictions_profile ON user_feature_restrictions(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_usage_profile ON user_feature_usage(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_access_grants_profile ON access_grants(profile_id, valid_until DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_access_grants_active ON access_grants(profile_id, is_active, valid_until DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_profile ON coupon_redemptions(profile_id);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Migration complete
|
|
||||||
-- ============================================================================
|
|
||||||
-- Run this migration with:
|
|
||||||
-- psql -h localhost -U mitai_prod -d mitai_prod < backend/migrations/v9c_subscription_system.sql
|
|
||||||
--
|
|
||||||
-- Or via Docker:
|
|
||||||
-- docker exec -i mitai-postgres psql -U mitai_prod -d mitai_prod < backend/migrations/v9c_subscription_system.sql
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
@ -27,7 +27,6 @@ class ProfileUpdate(BaseModel):
|
||||||
height: Optional[float] = None
|
height: Optional[float] = None
|
||||||
goal_weight: Optional[float] = None
|
goal_weight: Optional[float] = None
|
||||||
goal_bf_pct: Optional[float] = None
|
goal_bf_pct: Optional[float] = None
|
||||||
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tracking Models ───────────────────────────────────────────────────────────
|
# ── Tracking Models ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -85,9 +84,6 @@ class ActivityEntry(BaseModel):
|
||||||
rpe: Optional[int] = None
|
rpe: Optional[int] = None
|
||||||
source: Optional[str] = 'manual'
|
source: Optional[str] = 'manual'
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
training_type_id: Optional[int] = None # v9d: Training type categorization
|
|
||||||
training_category: Optional[str] = None # v9d: Denormalized category
|
|
||||||
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionDay(BaseModel):
|
class NutritionDay(BaseModel):
|
||||||
|
|
@ -114,12 +110,6 @@ class PasswordResetConfirm(BaseModel):
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
email: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
# ── Admin Models ──────────────────────────────────────────────────────────────
|
# ── Admin Models ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AdminProfileUpdate(BaseModel):
|
class AdminProfileUpdate(BaseModel):
|
||||||
|
|
@ -127,116 +117,3 @@ class AdminProfileUpdate(BaseModel):
|
||||||
ai_enabled: Optional[int] = None
|
ai_enabled: Optional[int] = None
|
||||||
ai_limit_day: Optional[int] = None
|
ai_limit_day: Optional[int] = None
|
||||||
export_enabled: Optional[int] = None
|
export_enabled: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
# ── Prompt Models (Issue #28) ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class PromptCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
display_name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
template: str
|
|
||||||
category: str = 'ganzheitlich'
|
|
||||||
active: bool = True
|
|
||||||
sort_order: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class PromptUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
display_name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
template: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
active: Optional[bool] = None
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PromptGenerateRequest(BaseModel):
|
|
||||||
goal: str
|
|
||||||
data_categories: list[str]
|
|
||||||
example_output: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Unified Prompt System Models (Issue #28 Phase 2) ───────────────────────
|
|
||||||
|
|
||||||
class StagePromptCreate(BaseModel):
|
|
||||||
"""Single prompt within a stage"""
|
|
||||||
source: str # 'inline' or 'reference'
|
|
||||||
slug: Optional[str] = None # Required if source='reference'
|
|
||||||
template: Optional[str] = None # Required if source='inline'
|
|
||||||
output_key: str # Key for storing result (e.g., 'nutrition', 'stage1_body')
|
|
||||||
output_format: str = 'text' # 'text' or 'json'
|
|
||||||
output_schema: Optional[dict] = None # JSON schema if output_format='json'
|
|
||||||
|
|
||||||
|
|
||||||
class StageCreate(BaseModel):
|
|
||||||
"""Single stage with multiple prompts"""
|
|
||||||
stage: int # Stage number (1, 2, 3, ...)
|
|
||||||
prompts: list[StagePromptCreate]
|
|
||||||
|
|
||||||
|
|
||||||
class UnifiedPromptCreate(BaseModel):
|
|
||||||
"""Create a new unified prompt (base or pipeline type)"""
|
|
||||||
name: str
|
|
||||||
slug: str
|
|
||||||
display_name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
type: str # 'base' or 'pipeline'
|
|
||||||
category: str = 'ganzheitlich'
|
|
||||||
active: bool = True
|
|
||||||
sort_order: int = 0
|
|
||||||
|
|
||||||
# For base prompts (single reusable template)
|
|
||||||
template: Optional[str] = None # Required if type='base'
|
|
||||||
output_format: str = 'text'
|
|
||||||
output_schema: Optional[dict] = None
|
|
||||||
|
|
||||||
# For pipeline prompts (multi-stage workflow)
|
|
||||||
stages: Optional[list[StageCreate]] = None # Required if type='pipeline'
|
|
||||||
|
|
||||||
|
|
||||||
class UnifiedPromptUpdate(BaseModel):
|
|
||||||
"""Update an existing unified prompt"""
|
|
||||||
name: Optional[str] = None
|
|
||||||
display_name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
type: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
active: Optional[bool] = None
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
template: Optional[str] = None
|
|
||||||
output_format: Optional[str] = None
|
|
||||||
output_schema: Optional[dict] = None
|
|
||||||
stages: Optional[list[StageCreate]] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pipeline Config Models (Issue #28) ─────────────────────────────────────
|
|
||||||
# NOTE: These will be deprecated in favor of UnifiedPrompt models above
|
|
||||||
|
|
||||||
class PipelineConfigCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_default: bool = False
|
|
||||||
active: bool = True
|
|
||||||
modules: dict # {"körper": true, "ernährung": true, ...}
|
|
||||||
timeframes: dict # {"körper": 30, "ernährung": 30, ...}
|
|
||||||
stage1_prompts: list[str] # Array of slugs
|
|
||||||
stage2_prompt: str # slug
|
|
||||||
stage3_prompt: Optional[str] = None # slug
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineConfigUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_default: Optional[bool] = None
|
|
||||||
active: Optional[bool] = None
|
|
||||||
modules: Optional[dict] = None
|
|
||||||
timeframes: Optional[dict] = None
|
|
||||||
stage1_prompts: Optional[list[str]] = None
|
|
||||||
stage2_prompt: Optional[str] = None
|
|
||||||
stage3_prompt: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineExecuteRequest(BaseModel):
|
|
||||||
config_id: Optional[str] = None # None = use default config
|
|
||||||
|
|
|
||||||
|
|
@ -1,365 +0,0 @@
|
||||||
"""
|
|
||||||
Placeholder Metadata System - Normative Standard Implementation
|
|
||||||
|
|
||||||
This module implements the normative standard for placeholder metadata
|
|
||||||
as defined in PLACEHOLDER_METADATA_REQUIREMENTS_V2_NORMATIVE.md
|
|
||||||
|
|
||||||
Version: 1.0.0
|
|
||||||
Status: Mandatory for all existing and future placeholders
|
|
||||||
"""
|
|
||||||
from dataclasses import dataclass, field, asdict
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional, List, Dict, Any, Callable
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
# ── Enums (Normative) ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class PlaceholderType(str, Enum):
|
|
||||||
"""Placeholder type classification (normative)."""
|
|
||||||
ATOMIC = "atomic" # Single atomic value (e.g., weight, age)
|
|
||||||
RAW_DATA = "raw_data" # Structured raw data (e.g., JSON lists)
|
|
||||||
INTERPRETED = "interpreted" # AI-interpreted/derived values
|
|
||||||
LEGACY_UNKNOWN = "legacy_unknown" # Legacy placeholder with unclear type
|
|
||||||
|
|
||||||
|
|
||||||
class TimeWindow(str, Enum):
|
|
||||||
"""Time window classification (normative)."""
|
|
||||||
LATEST = "latest" # Most recent value
|
|
||||||
DAYS_7 = "7d" # 7-day window
|
|
||||||
DAYS_14 = "14d" # 14-day window
|
|
||||||
DAYS_28 = "28d" # 28-day window
|
|
||||||
DAYS_30 = "30d" # 30-day window
|
|
||||||
DAYS_90 = "90d" # 90-day window
|
|
||||||
CUSTOM = "custom" # Custom time window (specify in notes)
|
|
||||||
MIXED = "mixed" # Multiple time windows in output
|
|
||||||
UNKNOWN = "unknown" # Time window unclear (legacy)
|
|
||||||
|
|
||||||
|
|
||||||
class OutputType(str, Enum):
|
|
||||||
"""Output data type (normative)."""
|
|
||||||
STRING = "string"
|
|
||||||
NUMBER = "number"
|
|
||||||
INTEGER = "integer"
|
|
||||||
BOOLEAN = "boolean"
|
|
||||||
JSON = "json"
|
|
||||||
MARKDOWN = "markdown"
|
|
||||||
DATE = "date"
|
|
||||||
ENUM = "enum"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class ConfidenceLevel(str, Enum):
|
|
||||||
"""Data confidence/quality level."""
|
|
||||||
HIGH = "high" # Sufficient data, reliable
|
|
||||||
MEDIUM = "medium" # Some data, potentially unreliable
|
|
||||||
LOW = "low" # Minimal data, unreliable
|
|
||||||
INSUFFICIENT = "insufficient" # No data or unusable
|
|
||||||
NOT_APPLICABLE = "not_applicable" # Confidence not relevant
|
|
||||||
|
|
||||||
|
|
||||||
# ── Data Classes (Normative) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MissingValuePolicy:
|
|
||||||
"""Policy for handling missing/unavailable values."""
|
|
||||||
legacy_display: str = "nicht verfügbar" # Legacy string for missing values
|
|
||||||
structured_null: bool = True # Return null in structured format
|
|
||||||
reason_codes: List[str] = field(default_factory=lambda: [
|
|
||||||
"no_data", "insufficient_data", "resolver_error"
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ExceptionHandling:
|
|
||||||
"""Exception handling strategy."""
|
|
||||||
on_error: str = "return_null_and_reason" # How to handle errors
|
|
||||||
notes: str = "Keine Exception bis in Prompt-Ebene durchreichen"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class QualityFilterPolicy:
|
|
||||||
"""Quality filter policy (if applicable)."""
|
|
||||||
enabled: bool = False
|
|
||||||
min_data_points: Optional[int] = None
|
|
||||||
min_confidence: Optional[ConfidenceLevel] = None
|
|
||||||
filter_criteria: Optional[str] = None
|
|
||||||
default_filter_level: Optional[str] = None # e.g., "quality", "acceptable", "all"
|
|
||||||
null_quality_handling: Optional[str] = None # e.g., "exclude", "include_as_uncategorized"
|
|
||||||
includes_poor: bool = False # Whether poor quality data is included
|
|
||||||
includes_excluded: bool = False # Whether excluded data is included
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConfidenceLogic:
|
|
||||||
"""Confidence/quality scoring logic."""
|
|
||||||
supported: bool = False
|
|
||||||
calculation: Optional[str] = None # How confidence is calculated
|
|
||||||
thresholds: Optional[Dict[str, Any]] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SourceInfo:
|
|
||||||
"""Technical source information."""
|
|
||||||
resolver: str # Resolver function name in PLACEHOLDER_MAP
|
|
||||||
module: str = "placeholder_resolver.py" # Module containing resolver
|
|
||||||
function: Optional[str] = None # Data layer function called
|
|
||||||
data_layer_module: Optional[str] = None # Data layer module (e.g., body_metrics.py)
|
|
||||||
source_tables: List[str] = field(default_factory=list) # Database tables
|
|
||||||
source_kind: str = "computed" # direct | computed | aggregated | derived | interpreted
|
|
||||||
code_reference: Optional[str] = None # Line reference (e.g., "placeholder_resolver.py:1083")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UsedBy:
|
|
||||||
"""Where the placeholder is used."""
|
|
||||||
prompts: List[str] = field(default_factory=list) # Prompt names/IDs
|
|
||||||
pipelines: List[str] = field(default_factory=list) # Pipeline names/IDs
|
|
||||||
charts: List[str] = field(default_factory=list) # Chart endpoint names
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlaceholderMetadata:
|
|
||||||
"""
|
|
||||||
Complete metadata for a placeholder (normative standard).
|
|
||||||
|
|
||||||
All fields are mandatory. Use None, [], or "unknown" for unresolved fields.
|
|
||||||
"""
|
|
||||||
# ── Core Identification ───────────────────────────────────────────────────
|
|
||||||
key: str # Placeholder key without braces (e.g., "weight_aktuell")
|
|
||||||
placeholder: str # Full placeholder with braces (e.g., "{{weight_aktuell}}")
|
|
||||||
category: str # Category (e.g., "Körper", "Ernährung")
|
|
||||||
|
|
||||||
# ── Type & Semantics ──────────────────────────────────────────────────────
|
|
||||||
type: PlaceholderType # atomic | raw_data | interpreted | legacy_unknown
|
|
||||||
description: str # Short description
|
|
||||||
semantic_contract: str # Precise semantic contract (what it represents)
|
|
||||||
|
|
||||||
# ── Data Format ───────────────────────────────────────────────────────────
|
|
||||||
unit: Optional[str] # Unit (e.g., "kg", "%", "Stunden")
|
|
||||||
time_window: TimeWindow # Time window for aggregation/calculation
|
|
||||||
output_type: OutputType # Data type of output
|
|
||||||
format_hint: Optional[str] # Example format (e.g., "85.8 kg")
|
|
||||||
example_output: Optional[str] # Example resolved value
|
|
||||||
|
|
||||||
# ── Runtime Values (populated during export) ──────────────────────────────
|
|
||||||
value_display: Optional[str] = None # Current resolved display value
|
|
||||||
value_raw: Optional[Any] = None # Current resolved raw value
|
|
||||||
available: bool = True # Whether value is currently available
|
|
||||||
missing_reason: Optional[str] = None # Reason if unavailable
|
|
||||||
|
|
||||||
# ── Error Handling ────────────────────────────────────────────────────────
|
|
||||||
missing_value_policy: MissingValuePolicy = field(default_factory=MissingValuePolicy)
|
|
||||||
exception_handling: ExceptionHandling = field(default_factory=ExceptionHandling)
|
|
||||||
|
|
||||||
# ── Quality & Confidence ──────────────────────────────────────────────────
|
|
||||||
quality_filter_policy: Optional[QualityFilterPolicy] = None
|
|
||||||
confidence_logic: Optional[ConfidenceLogic] = None
|
|
||||||
|
|
||||||
# ── Technical Source ──────────────────────────────────────────────────────
|
|
||||||
source: SourceInfo = field(default_factory=lambda: SourceInfo(resolver="unknown"))
|
|
||||||
dependencies: List[str] = field(default_factory=list) # Dependencies (e.g., "profile_id")
|
|
||||||
|
|
||||||
# ── Usage Tracking ────────────────────────────────────────────────────────
|
|
||||||
used_by: UsedBy = field(default_factory=UsedBy)
|
|
||||||
|
|
||||||
# ── Versioning & Lifecycle ────────────────────────────────────────────────
|
|
||||||
version: str = "1.0.0"
|
|
||||||
deprecated: bool = False
|
|
||||||
replacement: Optional[str] = None # Replacement placeholder if deprecated
|
|
||||||
|
|
||||||
# ── Issues & Notes ────────────────────────────────────────────────────────
|
|
||||||
known_issues: List[str] = field(default_factory=list)
|
|
||||||
notes: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
# ── Quality Assurance (Extended) ──────────────────────────────────────────
|
|
||||||
schema_status: str = "draft" # draft | validated | production
|
|
||||||
provenance_confidence: str = "medium" # low | medium | high
|
|
||||||
contract_source: str = "inferred" # inferred | documented | validated
|
|
||||||
legacy_contract_mismatch: bool = False # True if legacy description != implementation
|
|
||||||
metadata_completeness_score: int = 0 # 0-100, calculated
|
|
||||||
orphaned_placeholder: bool = False # True if not used in any prompt/pipeline/chart
|
|
||||||
unresolved_fields: List[str] = field(default_factory=list) # Fields that couldn't be resolved
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
"""Convert to dictionary with enum handling."""
|
|
||||||
result = asdict(self)
|
|
||||||
# Convert enums to strings
|
|
||||||
result['type'] = self.type.value
|
|
||||||
result['time_window'] = self.time_window.value
|
|
||||||
result['output_type'] = self.output_type.value
|
|
||||||
|
|
||||||
# Handle nested confidence level enums
|
|
||||||
if self.quality_filter_policy and self.quality_filter_policy.min_confidence:
|
|
||||||
result['quality_filter_policy']['min_confidence'] = \
|
|
||||||
self.quality_filter_policy.min_confidence.value
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def to_json(self) -> str:
|
|
||||||
"""Convert to JSON string."""
|
|
||||||
return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Validation ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ValidationViolation:
|
|
||||||
"""Represents a validation violation."""
|
|
||||||
field: str
|
|
||||||
issue: str
|
|
||||||
severity: str # error | warning
|
|
||||||
|
|
||||||
|
|
||||||
def validate_metadata(metadata: PlaceholderMetadata) -> List[ValidationViolation]:
|
|
||||||
"""
|
|
||||||
Validate metadata against normative standard.
|
|
||||||
|
|
||||||
Returns list of violations. Empty list means compliant.
|
|
||||||
"""
|
|
||||||
violations = []
|
|
||||||
|
|
||||||
# ── Mandatory Fields ──────────────────────────────────────────────────────
|
|
||||||
if not metadata.key or metadata.key == "unknown":
|
|
||||||
violations.append(ValidationViolation("key", "Key is required", "error"))
|
|
||||||
|
|
||||||
if not metadata.placeholder:
|
|
||||||
violations.append(ValidationViolation("placeholder", "Placeholder string required", "error"))
|
|
||||||
|
|
||||||
if not metadata.category:
|
|
||||||
violations.append(ValidationViolation("category", "Category is required", "error"))
|
|
||||||
|
|
||||||
if not metadata.description:
|
|
||||||
violations.append(ValidationViolation("description", "Description is required", "error"))
|
|
||||||
|
|
||||||
if not metadata.semantic_contract:
|
|
||||||
violations.append(ValidationViolation(
|
|
||||||
"semantic_contract",
|
|
||||||
"Semantic contract is required",
|
|
||||||
"error"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── Type Validation ───────────────────────────────────────────────────────
|
|
||||||
if metadata.type == PlaceholderType.LEGACY_UNKNOWN:
|
|
||||||
violations.append(ValidationViolation(
|
|
||||||
"type",
|
|
||||||
"Type LEGACY_UNKNOWN should be resolved",
|
|
||||||
"warning"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── Time Window Validation ────────────────────────────────────────────────
|
|
||||||
if metadata.time_window == TimeWindow.UNKNOWN:
|
|
||||||
violations.append(ValidationViolation(
|
|
||||||
"time_window",
|
|
||||||
"Time window UNKNOWN should be resolved",
|
|
||||||
"warning"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── Output Type Validation ────────────────────────────────────────────────
|
|
||||||
if metadata.output_type == OutputType.UNKNOWN:
|
|
||||||
violations.append(ValidationViolation(
|
|
||||||
"output_type",
|
|
||||||
"Output type UNKNOWN should be resolved",
|
|
||||||
"warning"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── Source Validation ─────────────────────────────────────────────────────
|
|
||||||
if metadata.source.resolver == "unknown":
|
|
||||||
violations.append(ValidationViolation(
|
|
||||||
"source.resolver",
|
|
||||||
"Resolver function must be specified",
|
|
||||||
"error"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── Deprecation Validation ────────────────────────────────────────────────
|
|
||||||
if metadata.deprecated and not metadata.replacement:
|
|
||||||
violations.append(ValidationViolation(
|
|
||||||
"replacement",
|
|
||||||
"Deprecated placeholder should have replacement",
|
|
||||||
"warning"
|
|
||||||
))
|
|
||||||
|
|
||||||
return violations
|
|
||||||
|
|
||||||
|
|
||||||
# ── Registry ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class PlaceholderMetadataRegistry:
|
|
||||||
"""
|
|
||||||
Central registry for all placeholder metadata.
|
|
||||||
|
|
||||||
This registry ensures all placeholders have complete metadata
|
|
||||||
and serves as the single source of truth for the export system.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._registry: Dict[str, PlaceholderMetadata] = {}
|
|
||||||
|
|
||||||
def register(self, metadata: PlaceholderMetadata, validate: bool = True) -> None:
|
|
||||||
"""
|
|
||||||
Register placeholder metadata.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: PlaceholderMetadata instance
|
|
||||||
validate: Whether to validate before registering
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If validation fails with errors
|
|
||||||
"""
|
|
||||||
if validate:
|
|
||||||
violations = validate_metadata(metadata)
|
|
||||||
errors = [v for v in violations if v.severity == "error"]
|
|
||||||
if errors:
|
|
||||||
error_msg = "\n".join([f" - {v.field}: {v.issue}" for v in errors])
|
|
||||||
raise ValueError(f"Metadata validation failed:\n{error_msg}")
|
|
||||||
|
|
||||||
self._registry[metadata.key] = metadata
|
|
||||||
|
|
||||||
def get(self, key: str) -> Optional[PlaceholderMetadata]:
|
|
||||||
"""Get metadata by key."""
|
|
||||||
return self._registry.get(key)
|
|
||||||
|
|
||||||
def get_all(self) -> Dict[str, PlaceholderMetadata]:
|
|
||||||
"""Get all registered metadata."""
|
|
||||||
return self._registry.copy()
|
|
||||||
|
|
||||||
def get_by_category(self) -> Dict[str, List[PlaceholderMetadata]]:
|
|
||||||
"""Get metadata grouped by category."""
|
|
||||||
by_category: Dict[str, List[PlaceholderMetadata]] = {}
|
|
||||||
for metadata in self._registry.values():
|
|
||||||
if metadata.category not in by_category:
|
|
||||||
by_category[metadata.category] = []
|
|
||||||
by_category[metadata.category].append(metadata)
|
|
||||||
return by_category
|
|
||||||
|
|
||||||
def get_deprecated(self) -> List[PlaceholderMetadata]:
|
|
||||||
"""Get all deprecated placeholders."""
|
|
||||||
return [m for m in self._registry.values() if m.deprecated]
|
|
||||||
|
|
||||||
def get_by_type(self, ptype: PlaceholderType) -> List[PlaceholderMetadata]:
|
|
||||||
"""Get placeholders by type."""
|
|
||||||
return [m for m in self._registry.values() if m.type == ptype]
|
|
||||||
|
|
||||||
def count(self) -> int:
|
|
||||||
"""Count registered placeholders."""
|
|
||||||
return len(self._registry)
|
|
||||||
|
|
||||||
def validate_all(self) -> Dict[str, List[ValidationViolation]]:
|
|
||||||
"""
|
|
||||||
Validate all registered placeholders.
|
|
||||||
|
|
||||||
Returns dict mapping key to list of violations.
|
|
||||||
"""
|
|
||||||
results = {}
|
|
||||||
for key, metadata in self._registry.items():
|
|
||||||
violations = validate_metadata(metadata)
|
|
||||||
if violations:
|
|
||||||
results[key] = violations
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
# Global registry instance
|
|
||||||
METADATA_REGISTRY = PlaceholderMetadataRegistry()
|
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
"""
|
|
||||||
Complete Placeholder Metadata Definitions
|
|
||||||
|
|
||||||
This module contains manually curated, complete metadata for all 116 placeholders.
|
|
||||||
It combines automatic extraction with manual annotation to ensure 100% normative compliance.
|
|
||||||
|
|
||||||
IMPORTANT: This is the authoritative source for placeholder metadata.
|
|
||||||
All new placeholders MUST be added here with complete metadata.
|
|
||||||
"""
|
|
||||||
from placeholder_metadata import (
|
|
||||||
PlaceholderMetadata,
|
|
||||||
PlaceholderType,
|
|
||||||
TimeWindow,
|
|
||||||
OutputType,
|
|
||||||
SourceInfo,
|
|
||||||
MissingValuePolicy,
|
|
||||||
ExceptionHandling,
|
|
||||||
ConfidenceLogic,
|
|
||||||
QualityFilterPolicy,
|
|
||||||
UsedBy,
|
|
||||||
ConfidenceLevel,
|
|
||||||
METADATA_REGISTRY
|
|
||||||
)
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
|
|
||||||
# ── Complete Metadata Definitions ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_all_placeholder_metadata() -> List[PlaceholderMetadata]:
|
|
||||||
"""
|
|
||||||
Returns complete metadata for all 116 placeholders.
|
|
||||||
|
|
||||||
This is the authoritative, manually curated source.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# PROFIL (4 placeholders)
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="name",
|
|
||||||
placeholder="{{name}}",
|
|
||||||
category="Profil",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Name des Nutzers",
|
|
||||||
semantic_contract="Name des Profils aus der Datenbank",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.STRING,
|
|
||||||
format_hint="Max Mustermann",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_profile_data",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_profile_data",
|
|
||||||
data_layer_module=None,
|
|
||||||
source_tables=["profiles"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
quality_filter_policy=None,
|
|
||||||
confidence_logic=None,
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="age",
|
|
||||||
placeholder="{{age}}",
|
|
||||||
category="Profil",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Alter in Jahren",
|
|
||||||
semantic_contract="Berechnet aus Geburtsdatum (dob) im Profil",
|
|
||||||
unit="Jahre",
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.INTEGER,
|
|
||||||
format_hint="35 Jahre",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="calculate_age",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="calculate_age",
|
|
||||||
data_layer_module=None,
|
|
||||||
source_tables=["profiles"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id", "dob"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="height",
|
|
||||||
placeholder="{{height}}",
|
|
||||||
category="Profil",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Körpergröße in cm",
|
|
||||||
semantic_contract="Körpergröße aus Profil",
|
|
||||||
unit="cm",
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.INTEGER,
|
|
||||||
format_hint="180 cm",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_profile_data",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_profile_data",
|
|
||||||
data_layer_module=None,
|
|
||||||
source_tables=["profiles"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="geschlecht",
|
|
||||||
placeholder="{{geschlecht}}",
|
|
||||||
category="Profil",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Geschlecht",
|
|
||||||
semantic_contract="Geschlecht aus Profil (m=männlich, w=weiblich)",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.ENUM,
|
|
||||||
format_hint="männlich | weiblich",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_profile_data",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_profile_data",
|
|
||||||
data_layer_module=None,
|
|
||||||
source_tables=["profiles"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# KÖRPER - Basic (11 placeholders)
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="weight_aktuell",
|
|
||||||
placeholder="{{weight_aktuell}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Aktuelles Gewicht in kg",
|
|
||||||
semantic_contract="Letzter verfügbarer Gewichtseintrag aus weight_log, keine Mittelung",
|
|
||||||
unit="kg",
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="85.8 kg",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_latest_weight",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_latest_weight_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["weight_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
confidence_logic=ConfidenceLogic(
|
|
||||||
supported=True,
|
|
||||||
calculation="Confidence = 'high' if data available, else 'insufficient'",
|
|
||||||
thresholds={"min_data_points": 1},
|
|
||||||
notes="Basiert auf data_layer.body_metrics.get_latest_weight_data"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="weight_trend",
|
|
||||||
placeholder="{{weight_trend}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.INTERPRETED,
|
|
||||||
description="Gewichtstrend (7d/30d)",
|
|
||||||
semantic_contract="Gewichtstrend-Beschreibung: stabil, steigend (+X kg), sinkend (-X kg), basierend auf 28d Daten",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.DAYS_28,
|
|
||||||
output_type=OutputType.STRING,
|
|
||||||
format_hint="stabil | steigend (+2.1 kg in 28 Tagen) | sinkend (-1.5 kg in 28 Tagen)",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_weight_trend",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_weight_trend_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["weight_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
known_issues=["time_window_inconsistent: Description says 7d/30d, actual implementation uses 28d"],
|
|
||||||
notes=["Consider deprecating in favor of explicit weight_trend_7d and weight_trend_28d"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="kf_aktuell",
|
|
||||||
placeholder="{{kf_aktuell}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Aktueller Körperfettanteil in %",
|
|
||||||
semantic_contract="Letzter berechneter Körperfettanteil aus caliper_log",
|
|
||||||
unit="%",
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="15.2%",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_latest_bf",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_body_composition_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["caliper_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="bmi",
|
|
||||||
placeholder="{{bmi}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Body Mass Index",
|
|
||||||
semantic_contract="BMI = weight / (height^2), berechnet aus aktuellem Gewicht und Profil-Größe",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="23.5",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="calculate_bmi",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="calculate_bmi",
|
|
||||||
data_layer_module=None,
|
|
||||||
source_tables=["weight_log", "profiles"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id", "height", "weight"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="caliper_summary",
|
|
||||||
placeholder="{{caliper_summary}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.RAW_DATA,
|
|
||||||
description="Zusammenfassung Caliper-Messungen",
|
|
||||||
semantic_contract="Strukturierte Zusammenfassung der letzten Caliper-Messungen mit Körperfettanteil",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.STRING,
|
|
||||||
format_hint="Text summary of caliper measurements",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_caliper_summary",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_body_composition_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["caliper_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
notes=["Returns formatted text summary, not JSON"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="circ_summary",
|
|
||||||
placeholder="{{circ_summary}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.RAW_DATA,
|
|
||||||
description="Zusammenfassung Umfangsmessungen",
|
|
||||||
semantic_contract="Best-of-Each Strategie: neueste Messung pro Körperstelle mit Altersangabe",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.MIXED,
|
|
||||||
output_type=OutputType.STRING,
|
|
||||||
format_hint="Text summary with measurements and age",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_circ_summary",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_circumference_summary_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["circumference_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
notes=["Best-of-Each strategy: latest measurement per body part"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="goal_weight",
|
|
||||||
placeholder="{{goal_weight}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Zielgewicht aus aktiven Zielen",
|
|
||||||
semantic_contract="Zielgewicht aus goals table (goal_type='weight'), falls aktiv",
|
|
||||||
unit="kg",
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="80.0 kg",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_goal_weight",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function=None,
|
|
||||||
data_layer_module=None,
|
|
||||||
source_tables=["goals"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id", "goals"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="goal_bf_pct",
|
|
||||||
placeholder="{{goal_bf_pct}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Ziel-Körperfettanteil aus aktiven Zielen",
|
|
||||||
semantic_contract="Ziel-Körperfettanteil aus goals table (goal_type='body_fat'), falls aktiv",
|
|
||||||
unit="%",
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="12.0%",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="get_goal_bf_pct",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function=None,
|
|
||||||
data_layer_module=None,
|
|
||||||
source_tables=["goals"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id", "goals"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="weight_7d_median",
|
|
||||||
placeholder="{{weight_7d_median}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Gewicht 7d Median (kg)",
|
|
||||||
semantic_contract="Median-Gewicht der letzten 7 Tage",
|
|
||||||
unit="kg",
|
|
||||||
time_window=TimeWindow.DAYS_7,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="85.5 kg",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="_safe_float",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_weight_trend_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["weight_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="weight_28d_slope",
|
|
||||||
placeholder="{{weight_28d_slope}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Gewichtstrend 28d (kg/Tag)",
|
|
||||||
semantic_contract="Lineare Regression slope für Gewichtstrend über 28 Tage (kg/Tag)",
|
|
||||||
unit="kg/Tag",
|
|
||||||
time_window=TimeWindow.DAYS_28,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="-0.05 kg/Tag",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="_safe_float",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_weight_trend_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["weight_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="fm_28d_change",
|
|
||||||
placeholder="{{fm_28d_change}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Fettmasse Änderung 28d (kg)",
|
|
||||||
semantic_contract="Absolute Änderung der Fettmasse über 28 Tage (kg)",
|
|
||||||
unit="kg",
|
|
||||||
time_window=TimeWindow.DAYS_28,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="-1.2 kg",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="_safe_float",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_body_composition_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["caliper_log", "weight_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
# KÖRPER - Advanced (6 placeholders)
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="lbm_28d_change",
|
|
||||||
placeholder="{{lbm_28d_change}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Magermasse Änderung 28d (kg)",
|
|
||||||
semantic_contract="Absolute Änderung der Magermasse (Lean Body Mass) über 28 Tage (kg)",
|
|
||||||
unit="kg",
|
|
||||||
time_window=TimeWindow.DAYS_28,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="+0.5 kg",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="_safe_float",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_body_composition_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["caliper_log", "weight_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="waist_28d_delta",
|
|
||||||
placeholder="{{waist_28d_delta}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Taillenumfang Änderung 28d (cm)",
|
|
||||||
semantic_contract="Absolute Änderung des Taillenumfangs über 28 Tage (cm)",
|
|
||||||
unit="cm",
|
|
||||||
time_window=TimeWindow.DAYS_28,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="-2.5 cm",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="_safe_float",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_circumference_summary_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["circumference_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="waist_hip_ratio",
|
|
||||||
placeholder="{{waist_hip_ratio}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.ATOMIC,
|
|
||||||
description="Taille/Hüfte-Verhältnis",
|
|
||||||
semantic_contract="Waist-to-Hip Ratio (WHR) = Taillenumfang / Hüftumfang",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.LATEST,
|
|
||||||
output_type=OutputType.NUMBER,
|
|
||||||
format_hint="0.85",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="_safe_float",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_circumference_summary_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["circumference_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
),
|
|
||||||
|
|
||||||
PlaceholderMetadata(
|
|
||||||
key="recomposition_quadrant",
|
|
||||||
placeholder="{{recomposition_quadrant}}",
|
|
||||||
category="Körper",
|
|
||||||
type=PlaceholderType.INTERPRETED,
|
|
||||||
description="Rekomposition-Status",
|
|
||||||
semantic_contract="Klassifizierung basierend auf FM/LBM Änderungen: 'Optimal Recomposition', 'Fat Loss', 'Muscle Gain', 'Weight Gain'",
|
|
||||||
unit=None,
|
|
||||||
time_window=TimeWindow.DAYS_28,
|
|
||||||
output_type=OutputType.ENUM,
|
|
||||||
format_hint="Optimal Recomposition | Fat Loss | Muscle Gain | Weight Gain",
|
|
||||||
example_output=None,
|
|
||||||
source=SourceInfo(
|
|
||||||
resolver="_safe_str",
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function="get_body_composition_data",
|
|
||||||
data_layer_module="body_metrics",
|
|
||||||
source_tables=["caliper_log", "weight_log"]
|
|
||||||
),
|
|
||||||
dependencies=["profile_id"],
|
|
||||||
notes=["Quadrant-Logik basiert auf FM/LBM Delta-Vorzeichen"],
|
|
||||||
),
|
|
||||||
|
|
||||||
# NOTE: Continuing with all 116 placeholders would make this file very long.
|
|
||||||
# For brevity, I'll create a separate generator that fills all remaining placeholders.
|
|
||||||
# The pattern is established above - each placeholder gets full metadata.
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def register_all_metadata():
|
|
||||||
"""
|
|
||||||
Register all placeholder metadata in the global registry.
|
|
||||||
|
|
||||||
This should be called at application startup to populate the registry.
|
|
||||||
"""
|
|
||||||
all_metadata = get_all_placeholder_metadata()
|
|
||||||
|
|
||||||
for metadata in all_metadata:
|
|
||||||
try:
|
|
||||||
METADATA_REGISTRY.register(metadata, validate=False)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Failed to register {metadata.key}: {e}")
|
|
||||||
|
|
||||||
print(f"Registered {METADATA_REGISTRY.count()} placeholders in metadata registry")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
register_all_metadata()
|
|
||||||
print(f"\nTotal placeholders registered: {METADATA_REGISTRY.count()}")
|
|
||||||
|
|
||||||
# Show validation report
|
|
||||||
violations = METADATA_REGISTRY.validate_all()
|
|
||||||
if violations:
|
|
||||||
print(f"\nValidation issues found for {len(violations)} placeholders:")
|
|
||||||
for key, issues in list(violations.items())[:5]:
|
|
||||||
print(f"\n{key}:")
|
|
||||||
for issue in issues:
|
|
||||||
print(f" [{issue.severity}] {issue.field}: {issue.issue}")
|
|
||||||
else:
|
|
||||||
print("\nAll placeholders pass validation! ✓")
|
|
||||||
|
|
@ -1,417 +0,0 @@
|
||||||
"""
|
|
||||||
Enhanced Placeholder Metadata Extraction
|
|
||||||
|
|
||||||
Improved extraction logic that addresses quality issues:
|
|
||||||
1. Correct value_raw extraction
|
|
||||||
2. Accurate unit inference
|
|
||||||
3. Precise time_window detection
|
|
||||||
4. Real source provenance
|
|
||||||
5. Quality filter policies for activity placeholders
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
from typing import Any, Optional, Tuple, Dict
|
|
||||||
from placeholder_metadata import (
|
|
||||||
PlaceholderType,
|
|
||||||
TimeWindow,
|
|
||||||
OutputType,
|
|
||||||
QualityFilterPolicy,
|
|
||||||
ConfidenceLogic,
|
|
||||||
ConfidenceLevel
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Enhanced Value Raw Extraction ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def extract_value_raw(value_display: str, output_type: OutputType, placeholder_type: PlaceholderType) -> Tuple[Any, bool]:
|
|
||||||
"""
|
|
||||||
Extract raw value from display string.
|
|
||||||
|
|
||||||
Returns: (raw_value, success)
|
|
||||||
"""
|
|
||||||
if not value_display or value_display in ['nicht verfügbar', 'nicht genug Daten']:
|
|
||||||
return None, True
|
|
||||||
|
|
||||||
# JSON output type
|
|
||||||
if output_type == OutputType.JSON:
|
|
||||||
try:
|
|
||||||
return json.loads(value_display), True
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
# Try to find JSON in string
|
|
||||||
json_match = re.search(r'(\{.*\}|\[.*\])', value_display, re.DOTALL)
|
|
||||||
if json_match:
|
|
||||||
try:
|
|
||||||
return json.loads(json_match.group(1)), True
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return None, False
|
|
||||||
|
|
||||||
# Markdown output type
|
|
||||||
if output_type == OutputType.MARKDOWN:
|
|
||||||
return value_display, True
|
|
||||||
|
|
||||||
# Number types
|
|
||||||
if output_type in [OutputType.NUMBER, OutputType.INTEGER]:
|
|
||||||
# Extract first number from string
|
|
||||||
match = re.search(r'([-+]?\d+\.?\d*)', value_display)
|
|
||||||
if match:
|
|
||||||
val = float(match.group(1))
|
|
||||||
return int(val) if output_type == OutputType.INTEGER else val, True
|
|
||||||
return None, False
|
|
||||||
|
|
||||||
# Date
|
|
||||||
if output_type == OutputType.DATE:
|
|
||||||
# Check if already ISO format
|
|
||||||
if re.match(r'\d{4}-\d{2}-\d{2}', value_display):
|
|
||||||
return value_display, True
|
|
||||||
return value_display, False # Unknown format
|
|
||||||
|
|
||||||
# String/Enum - return as-is
|
|
||||||
return value_display, True
|
|
||||||
|
|
||||||
|
|
||||||
# ── Enhanced Unit Inference ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def infer_unit_strict(key: str, description: str, output_type: OutputType, placeholder_type: PlaceholderType) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Strict unit inference - only return unit if certain.
|
|
||||||
|
|
||||||
NO units for:
|
|
||||||
- Scores (dimensionless)
|
|
||||||
- Correlations (dimensionless)
|
|
||||||
- Percentages expressed as 0-100 scale
|
|
||||||
- Classifications/enums
|
|
||||||
- JSON/Markdown outputs
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
desc_lower = description.lower()
|
|
||||||
|
|
||||||
# JSON/Markdown never have units
|
|
||||||
if output_type in [OutputType.JSON, OutputType.MARKDOWN, OutputType.ENUM]:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Scores are dimensionless (0-100 scale)
|
|
||||||
if 'score' in key_lower or 'adequacy' in key_lower:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Correlations are dimensionless
|
|
||||||
if 'correlation' in key_lower:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Ratios/percentages on 0-100 scale
|
|
||||||
if any(x in key_lower for x in ['pct', 'ratio', 'balance', 'compliance', 'consistency']):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Classifications/quadrants
|
|
||||||
if 'quadrant' in key_lower or 'classification' in key_lower:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Weight/mass
|
|
||||||
if any(x in key_lower for x in ['weight', 'gewicht', 'fm_', 'lbm_', 'masse']):
|
|
||||||
return 'kg'
|
|
||||||
|
|
||||||
# Circumferences/lengths
|
|
||||||
if any(x in key_lower for x in ['umfang', 'waist', 'hip', 'chest', 'arm', 'leg', 'delta']) and 'circumference' in desc_lower:
|
|
||||||
return 'cm'
|
|
||||||
|
|
||||||
# Time durations
|
|
||||||
if any(x in key_lower for x in ['duration', 'dauer', 'debt']):
|
|
||||||
if 'hours' in desc_lower or 'stunden' in desc_lower:
|
|
||||||
return 'Stunden'
|
|
||||||
elif 'minutes' in desc_lower or 'minuten' in desc_lower:
|
|
||||||
return 'Minuten'
|
|
||||||
return None # Unclear
|
|
||||||
|
|
||||||
# Heart rate
|
|
||||||
if 'rhr' in key_lower or ('hr' in key_lower and 'hrv' not in key_lower) or 'puls' in key_lower:
|
|
||||||
return 'bpm'
|
|
||||||
|
|
||||||
# HRV
|
|
||||||
if 'hrv' in key_lower:
|
|
||||||
return 'ms'
|
|
||||||
|
|
||||||
# VO2 Max
|
|
||||||
if 'vo2' in key_lower:
|
|
||||||
return 'ml/kg/min'
|
|
||||||
|
|
||||||
# Calories/energy
|
|
||||||
if 'kcal' in key_lower or 'energy' in key_lower or 'energie' in key_lower:
|
|
||||||
return 'kcal'
|
|
||||||
|
|
||||||
# Macros (protein, carbs, fat)
|
|
||||||
if any(x in key_lower for x in ['protein', 'carb', 'fat', 'kohlenhydrat', 'fett']) and 'g' in desc_lower:
|
|
||||||
return 'g'
|
|
||||||
|
|
||||||
# Height
|
|
||||||
if 'height' in key_lower or 'größe' in key_lower:
|
|
||||||
return 'cm'
|
|
||||||
|
|
||||||
# Age
|
|
||||||
if 'age' in key_lower or 'alter' in key_lower:
|
|
||||||
return 'Jahre'
|
|
||||||
|
|
||||||
# BMI is dimensionless
|
|
||||||
if 'bmi' in key_lower:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Default: No unit (conservative)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Enhanced Time Window Detection ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def detect_time_window_precise(
|
|
||||||
key: str,
|
|
||||||
description: str,
|
|
||||||
resolver_name: str,
|
|
||||||
semantic_contract: str
|
|
||||||
) -> Tuple[TimeWindow, bool, Optional[str]]:
|
|
||||||
"""
|
|
||||||
Detect time window with precision.
|
|
||||||
|
|
||||||
Returns: (time_window, is_certain, mismatch_note)
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
desc_lower = description.lower()
|
|
||||||
contract_lower = semantic_contract.lower()
|
|
||||||
|
|
||||||
# Explicit suffixes (highest confidence)
|
|
||||||
if '_7d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_7, True, None
|
|
||||||
if '_14d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_14, True, None
|
|
||||||
if '_28d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_28, True, None
|
|
||||||
if '_30d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_30, True, None
|
|
||||||
if '_90d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_90, True, None
|
|
||||||
if '_3d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_7, True, None # Map 3d to closest standard
|
|
||||||
|
|
||||||
# Latest/current
|
|
||||||
if any(x in key_lower for x in ['aktuell', 'latest', 'current', 'letzter']):
|
|
||||||
return TimeWindow.LATEST, True, None
|
|
||||||
|
|
||||||
# Check semantic contract for time window info
|
|
||||||
if '7 tag' in contract_lower or '7d' in contract_lower:
|
|
||||||
# Check for description mismatch
|
|
||||||
mismatch = None
|
|
||||||
if '30' in desc_lower or '28' in desc_lower:
|
|
||||||
mismatch = f"Description says 30d/28d but implementation is 7d"
|
|
||||||
return TimeWindow.DAYS_7, True, mismatch
|
|
||||||
|
|
||||||
if '28 tag' in contract_lower or '28d' in contract_lower:
|
|
||||||
mismatch = None
|
|
||||||
if '7' in desc_lower and '28' not in desc_lower:
|
|
||||||
mismatch = f"Description says 7d but implementation is 28d"
|
|
||||||
return TimeWindow.DAYS_28, True, mismatch
|
|
||||||
|
|
||||||
if '30 tag' in contract_lower or '30d' in contract_lower:
|
|
||||||
return TimeWindow.DAYS_30, True, None
|
|
||||||
|
|
||||||
if '90 tag' in contract_lower or '90d' in contract_lower:
|
|
||||||
return TimeWindow.DAYS_90, True, None
|
|
||||||
|
|
||||||
# Check description patterns
|
|
||||||
if 'letzte 7' in desc_lower or '7 tag' in desc_lower:
|
|
||||||
return TimeWindow.DAYS_7, False, None
|
|
||||||
|
|
||||||
if 'letzte 30' in desc_lower or '30 tag' in desc_lower:
|
|
||||||
return TimeWindow.DAYS_30, False, None
|
|
||||||
|
|
||||||
# Averages typically 30d unless specified
|
|
||||||
if 'avg' in key_lower or 'durchschn' in key_lower:
|
|
||||||
if '7' in desc_lower:
|
|
||||||
return TimeWindow.DAYS_7, False, None
|
|
||||||
return TimeWindow.DAYS_30, False, "Assumed 30d for average (not explicit)"
|
|
||||||
|
|
||||||
# Trends typically 28d
|
|
||||||
if 'trend' in key_lower:
|
|
||||||
return TimeWindow.DAYS_28, False, "Assumed 28d for trend"
|
|
||||||
|
|
||||||
# Week-based
|
|
||||||
if 'week' in key_lower or 'woche' in key_lower:
|
|
||||||
return TimeWindow.DAYS_7, False, None
|
|
||||||
|
|
||||||
# Profile data is latest
|
|
||||||
if key_lower in ['name', 'age', 'height', 'geschlecht']:
|
|
||||||
return TimeWindow.LATEST, True, None
|
|
||||||
|
|
||||||
# Unknown
|
|
||||||
return TimeWindow.UNKNOWN, False, "Could not determine time window from code or documentation"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Enhanced Source Provenance ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def resolve_real_source(resolver_name: str) -> Tuple[Optional[str], Optional[str], list, str]:
|
|
||||||
"""
|
|
||||||
Resolve real source function (not safe wrappers).
|
|
||||||
|
|
||||||
Returns: (function, data_layer_module, source_tables, source_kind)
|
|
||||||
"""
|
|
||||||
# Skip safe wrappers - they're not real sources
|
|
||||||
if resolver_name in ['_safe_int', '_safe_float', '_safe_json', '_safe_str']:
|
|
||||||
return None, None, [], "wrapper"
|
|
||||||
|
|
||||||
# Direct mappings to data layer
|
|
||||||
source_map = {
|
|
||||||
# Body metrics
|
|
||||||
'get_latest_weight': ('get_latest_weight_data', 'body_metrics', ['weight_log'], 'direct'),
|
|
||||||
'get_weight_trend': ('get_weight_trend_data', 'body_metrics', ['weight_log'], 'computed'),
|
|
||||||
'get_latest_bf': ('get_body_composition_data', 'body_metrics', ['caliper_log'], 'direct'),
|
|
||||||
'get_circ_summary': ('get_circumference_summary_data', 'body_metrics', ['circumference_log'], 'aggregated'),
|
|
||||||
'get_caliper_summary': ('get_body_composition_data', 'body_metrics', ['caliper_log'], 'aggregated'),
|
|
||||||
'calculate_bmi': (None, None, ['weight_log', 'profiles'], 'computed'),
|
|
||||||
|
|
||||||
# Nutrition
|
|
||||||
'get_nutrition_avg': ('get_nutrition_average_data', 'nutrition_metrics', ['nutrition_log'], 'aggregated'),
|
|
||||||
'get_protein_per_kg': ('get_protein_targets_data', 'nutrition_metrics', ['nutrition_log', 'weight_log'], 'computed'),
|
|
||||||
'get_nutrition_days': ('get_nutrition_days_data', 'nutrition_metrics', ['nutrition_log'], 'computed'),
|
|
||||||
|
|
||||||
# Activity
|
|
||||||
'get_activity_summary': ('get_activity_summary_data', 'activity_metrics', ['activity_log', 'training_types'], 'aggregated'),
|
|
||||||
'get_activity_detail': ('get_activity_detail_data', 'activity_metrics', ['activity_log', 'training_types'], 'aggregated'),
|
|
||||||
'get_training_type_dist': ('get_training_type_distribution_data', 'activity_metrics', ['activity_log', 'training_types'], 'aggregated'),
|
|
||||||
|
|
||||||
# Sleep
|
|
||||||
'get_sleep_duration': ('get_sleep_duration_data', 'recovery_metrics', ['sleep_log'], 'aggregated'),
|
|
||||||
'get_sleep_quality': ('get_sleep_quality_data', 'recovery_metrics', ['sleep_log'], 'computed'),
|
|
||||||
|
|
||||||
# Vitals
|
|
||||||
'get_resting_hr': ('get_resting_heart_rate_data', 'health_metrics', ['vitals_baseline'], 'direct'),
|
|
||||||
'get_hrv': ('get_heart_rate_variability_data', 'health_metrics', ['vitals_baseline'], 'direct'),
|
|
||||||
'get_vo2_max': ('get_vo2_max_data', 'health_metrics', ['vitals_baseline'], 'direct'),
|
|
||||||
|
|
||||||
# Profile
|
|
||||||
'get_profile_data': (None, None, ['profiles'], 'direct'),
|
|
||||||
'calculate_age': (None, None, ['profiles'], 'computed'),
|
|
||||||
|
|
||||||
# Goals
|
|
||||||
'get_goal_weight': (None, None, ['goals'], 'direct'),
|
|
||||||
'get_goal_bf_pct': (None, None, ['goals'], 'direct'),
|
|
||||||
}
|
|
||||||
|
|
||||||
if resolver_name in source_map:
|
|
||||||
return source_map[resolver_name]
|
|
||||||
|
|
||||||
# Goals formatting functions
|
|
||||||
if resolver_name.startswith('_format_goals'):
|
|
||||||
return (None, None, ['goals', 'goal_focus_contributions'], 'interpreted')
|
|
||||||
|
|
||||||
# Unknown
|
|
||||||
return None, None, [], "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Quality Filter Policy for Activity Placeholders ───────────────────────────
|
|
||||||
|
|
||||||
def create_activity_quality_policy(key: str) -> Optional[QualityFilterPolicy]:
|
|
||||||
"""
|
|
||||||
Create quality filter policy for activity-related placeholders.
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
|
|
||||||
# Activity-related placeholders need quality policies
|
|
||||||
if any(x in key_lower for x in ['activity', 'training', 'load', 'volume', 'quality_session', 'ability']):
|
|
||||||
return QualityFilterPolicy(
|
|
||||||
enabled=True,
|
|
||||||
default_filter_level="quality",
|
|
||||||
null_quality_handling="exclude",
|
|
||||||
includes_poor=False,
|
|
||||||
includes_excluded=False,
|
|
||||||
notes="Activity metrics filter for quality='quality' by default. NULL quality_label excluded."
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Confidence Logic Creation ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def create_confidence_logic(key: str, data_layer_module: Optional[str]) -> Optional[ConfidenceLogic]:
|
|
||||||
"""
|
|
||||||
Create confidence logic if applicable.
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
|
|
||||||
# Data layer functions typically have confidence
|
|
||||||
if data_layer_module:
|
|
||||||
return ConfidenceLogic(
|
|
||||||
supported=True,
|
|
||||||
calculation="Based on data availability and quality thresholds",
|
|
||||||
thresholds={"min_data_points": 1},
|
|
||||||
notes=f"Confidence determined by {data_layer_module}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scores have implicit confidence
|
|
||||||
if 'score' in key_lower:
|
|
||||||
return ConfidenceLogic(
|
|
||||||
supported=True,
|
|
||||||
calculation="Based on data completeness for score components",
|
|
||||||
notes="Score confidence correlates with input data availability"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Correlations have confidence
|
|
||||||
if 'correlation' in key_lower:
|
|
||||||
return ConfidenceLogic(
|
|
||||||
supported=True,
|
|
||||||
calculation="Pearson correlation with significance testing",
|
|
||||||
thresholds={"min_data_points": 7},
|
|
||||||
notes="Requires minimum 7 data points for meaningful correlation"
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Metadata Completeness Score ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def calculate_completeness_score(metadata_dict: Dict) -> int:
|
|
||||||
"""
|
|
||||||
Calculate metadata completeness score (0-100).
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
- Required fields filled
|
|
||||||
- Time window not unknown
|
|
||||||
- Output type not unknown
|
|
||||||
- Unit specified (if applicable)
|
|
||||||
- Source provenance complete
|
|
||||||
- Quality/confidence policies (if applicable)
|
|
||||||
"""
|
|
||||||
score = 0
|
|
||||||
max_score = 100
|
|
||||||
|
|
||||||
# Required fields (30 points)
|
|
||||||
if metadata_dict.get('category') and metadata_dict['category'] != 'Unknown':
|
|
||||||
score += 5
|
|
||||||
if metadata_dict.get('description') and 'No description' not in metadata_dict['description']:
|
|
||||||
score += 5
|
|
||||||
if metadata_dict.get('semantic_contract'):
|
|
||||||
score += 10
|
|
||||||
if metadata_dict.get('source', {}).get('resolver') and metadata_dict['source']['resolver'] != 'unknown':
|
|
||||||
score += 10
|
|
||||||
|
|
||||||
# Type specification (20 points)
|
|
||||||
if metadata_dict.get('type') and metadata_dict['type'] != 'legacy_unknown':
|
|
||||||
score += 10
|
|
||||||
if metadata_dict.get('time_window') and metadata_dict['time_window'] != 'unknown':
|
|
||||||
score += 10
|
|
||||||
|
|
||||||
# Output specification (20 points)
|
|
||||||
if metadata_dict.get('output_type') and metadata_dict['output_type'] != 'unknown':
|
|
||||||
score += 10
|
|
||||||
if metadata_dict.get('format_hint'):
|
|
||||||
score += 10
|
|
||||||
|
|
||||||
# Source provenance (20 points)
|
|
||||||
source = metadata_dict.get('source', {})
|
|
||||||
if source.get('data_layer_module'):
|
|
||||||
score += 10
|
|
||||||
if source.get('source_tables'):
|
|
||||||
score += 10
|
|
||||||
|
|
||||||
# Quality policies (10 points)
|
|
||||||
if metadata_dict.get('quality_filter_policy'):
|
|
||||||
score += 5
|
|
||||||
if metadata_dict.get('confidence_logic'):
|
|
||||||
score += 5
|
|
||||||
|
|
||||||
return min(score, max_score)
|
|
||||||
|
|
@ -1,551 +0,0 @@
|
||||||
"""
|
|
||||||
Placeholder Metadata Extractor
|
|
||||||
|
|
||||||
Automatically extracts metadata from existing codebase for all placeholders.
|
|
||||||
This module bridges the gap between legacy implementation and normative standard.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import inspect
|
|
||||||
from typing import Dict, List, Optional, Tuple, Any
|
|
||||||
from placeholder_metadata import (
|
|
||||||
PlaceholderMetadata,
|
|
||||||
PlaceholderMetadataRegistry,
|
|
||||||
PlaceholderType,
|
|
||||||
TimeWindow,
|
|
||||||
OutputType,
|
|
||||||
SourceInfo,
|
|
||||||
MissingValuePolicy,
|
|
||||||
ExceptionHandling,
|
|
||||||
ConfidenceLogic,
|
|
||||||
QualityFilterPolicy,
|
|
||||||
UsedBy,
|
|
||||||
METADATA_REGISTRY
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Heuristics ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def infer_type_from_key(key: str, description: str) -> PlaceholderType:
|
|
||||||
"""
|
|
||||||
Infer placeholder type from key and description.
|
|
||||||
|
|
||||||
Heuristics:
|
|
||||||
- JSON/Markdown in name → interpreted or raw_data
|
|
||||||
- "score", "pct", "ratio" → atomic
|
|
||||||
- "summary", "detail" → raw_data or interpreted
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
desc_lower = description.lower()
|
|
||||||
|
|
||||||
# JSON/Markdown outputs
|
|
||||||
if '_json' in key_lower or '_md' in key_lower:
|
|
||||||
return PlaceholderType.RAW_DATA
|
|
||||||
|
|
||||||
# Scores and percentages are atomic
|
|
||||||
if any(x in key_lower for x in ['score', 'pct', '_vs_', 'ratio', 'adequacy']):
|
|
||||||
return PlaceholderType.ATOMIC
|
|
||||||
|
|
||||||
# Summaries and details
|
|
||||||
if any(x in key_lower for x in ['summary', 'detail', 'verteilung', 'distribution']):
|
|
||||||
return PlaceholderType.RAW_DATA
|
|
||||||
|
|
||||||
# Goals and focus areas (interpreted)
|
|
||||||
if any(x in key_lower for x in ['goal', 'focus', 'top_']):
|
|
||||||
return PlaceholderType.INTERPRETED
|
|
||||||
|
|
||||||
# Correlations are interpreted
|
|
||||||
if 'correlation' in key_lower or 'plateau' in key_lower or 'driver' in key_lower:
|
|
||||||
return PlaceholderType.INTERPRETED
|
|
||||||
|
|
||||||
# Default: atomic
|
|
||||||
return PlaceholderType.ATOMIC
|
|
||||||
|
|
||||||
|
|
||||||
def infer_time_window_from_key(key: str) -> TimeWindow:
|
|
||||||
"""
|
|
||||||
Infer time window from placeholder key.
|
|
||||||
|
|
||||||
Patterns:
|
|
||||||
- _7d → 7d
|
|
||||||
- _28d → 28d
|
|
||||||
- _30d → 30d
|
|
||||||
- _90d → 90d
|
|
||||||
- aktuell, latest, current → latest
|
|
||||||
- avg, median → usually 28d or 30d (default to 30d)
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
|
|
||||||
# Explicit time windows
|
|
||||||
if '_7d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_7
|
|
||||||
if '_14d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_14
|
|
||||||
if '_28d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_28
|
|
||||||
if '_30d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_30
|
|
||||||
if '_90d' in key_lower:
|
|
||||||
return TimeWindow.DAYS_90
|
|
||||||
|
|
||||||
# Latest/current
|
|
||||||
if any(x in key_lower for x in ['aktuell', 'latest', 'current', 'letzt']):
|
|
||||||
return TimeWindow.LATEST
|
|
||||||
|
|
||||||
# Averages default to 30d
|
|
||||||
if 'avg' in key_lower or 'durchschn' in key_lower:
|
|
||||||
return TimeWindow.DAYS_30
|
|
||||||
|
|
||||||
# Trends default to 28d
|
|
||||||
if 'trend' in key_lower:
|
|
||||||
return TimeWindow.DAYS_28
|
|
||||||
|
|
||||||
# Week-based metrics
|
|
||||||
if 'week' in key_lower or 'woche' in key_lower:
|
|
||||||
return TimeWindow.DAYS_7
|
|
||||||
|
|
||||||
# Profile data is always latest
|
|
||||||
if key_lower in ['name', 'age', 'height', 'geschlecht']:
|
|
||||||
return TimeWindow.LATEST
|
|
||||||
|
|
||||||
# Default: unknown
|
|
||||||
return TimeWindow.UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
def infer_output_type_from_key(key: str) -> OutputType:
|
|
||||||
"""
|
|
||||||
Infer output data type from key.
|
|
||||||
|
|
||||||
Heuristics:
|
|
||||||
- _json → json
|
|
||||||
- _md → markdown
|
|
||||||
- score, pct, ratio → integer
|
|
||||||
- avg, median, delta, change → number
|
|
||||||
- name, geschlecht → string
|
|
||||||
- datum, date → date
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
|
|
||||||
if '_json' in key_lower:
|
|
||||||
return OutputType.JSON
|
|
||||||
if '_md' in key_lower:
|
|
||||||
return OutputType.MARKDOWN
|
|
||||||
if key_lower in ['datum_heute', 'zeitraum_7d', 'zeitraum_30d', 'zeitraum_90d']:
|
|
||||||
return OutputType.DATE
|
|
||||||
if any(x in key_lower for x in ['score', 'pct', 'count', 'days', 'frequency']):
|
|
||||||
return OutputType.INTEGER
|
|
||||||
if any(x in key_lower for x in ['avg', 'median', 'delta', 'change', 'slope',
|
|
||||||
'weight', 'ratio', 'balance', 'trend']):
|
|
||||||
return OutputType.NUMBER
|
|
||||||
if key_lower in ['name', 'geschlecht', 'quadrant']:
|
|
||||||
return OutputType.STRING
|
|
||||||
|
|
||||||
# Default: string (most placeholders format to string for AI)
|
|
||||||
return OutputType.STRING
|
|
||||||
|
|
||||||
|
|
||||||
def infer_unit_from_key_and_description(key: str, description: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Infer unit from key and description.
|
|
||||||
|
|
||||||
Common units:
|
|
||||||
- weight → kg
|
|
||||||
- duration, time → Stunden or Minuten
|
|
||||||
- percentage → %
|
|
||||||
- distance → km
|
|
||||||
- heart rate → bpm
|
|
||||||
"""
|
|
||||||
key_lower = key.lower()
|
|
||||||
desc_lower = description.lower()
|
|
||||||
|
|
||||||
# Weight
|
|
||||||
if 'weight' in key_lower or 'gewicht' in key_lower or any(x in key_lower for x in ['fm_', 'lbm_']):
|
|
||||||
return 'kg'
|
|
||||||
|
|
||||||
# Body fat, percentages
|
|
||||||
if any(x in key_lower for x in ['kf_', 'pct', '_bf', 'adequacy', 'score',
|
|
||||||
'balance', 'compliance', 'quality']):
|
|
||||||
return '%'
|
|
||||||
|
|
||||||
# Circumferences
|
|
||||||
if any(x in key_lower for x in ['umfang', 'waist', 'hip', 'chest', 'arm', 'leg']):
|
|
||||||
return 'cm'
|
|
||||||
|
|
||||||
# Time/duration
|
|
||||||
if any(x in key_lower for x in ['duration', 'dauer', 'hours', 'stunden', 'minutes', 'debt']):
|
|
||||||
if 'hours' in desc_lower or 'stunden' in desc_lower:
|
|
||||||
return 'Stunden'
|
|
||||||
elif 'minutes' in desc_lower or 'minuten' in desc_lower:
|
|
||||||
return 'Minuten'
|
|
||||||
else:
|
|
||||||
return 'Stunden' # Default
|
|
||||||
|
|
||||||
# Heart rate
|
|
||||||
if 'hr' in key_lower or 'herzfrequenz' in key_lower or 'puls' in key_lower:
|
|
||||||
return 'bpm'
|
|
||||||
|
|
||||||
# HRV
|
|
||||||
if 'hrv' in key_lower:
|
|
||||||
return 'ms'
|
|
||||||
|
|
||||||
# VO2 Max
|
|
||||||
if 'vo2' in key_lower:
|
|
||||||
return 'ml/kg/min'
|
|
||||||
|
|
||||||
# Calories/energy
|
|
||||||
if 'kcal' in key_lower or 'energy' in key_lower or 'energie' in key_lower:
|
|
||||||
return 'kcal'
|
|
||||||
|
|
||||||
# Macros
|
|
||||||
if any(x in key_lower for x in ['protein', 'carb', 'fat', 'kohlenhydrat', 'fett']):
|
|
||||||
return 'g'
|
|
||||||
|
|
||||||
# Height
|
|
||||||
if 'height' in key_lower or 'größe' in key_lower:
|
|
||||||
return 'cm'
|
|
||||||
|
|
||||||
# Age
|
|
||||||
if 'age' in key_lower or 'alter' in key_lower:
|
|
||||||
return 'Jahre'
|
|
||||||
|
|
||||||
# BMI
|
|
||||||
if 'bmi' in key_lower:
|
|
||||||
return None # BMI has no unit
|
|
||||||
|
|
||||||
# Load
|
|
||||||
if 'load' in key_lower:
|
|
||||||
return None # Unitless
|
|
||||||
|
|
||||||
# Default: None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_resolver_name(resolver_func) -> str:
|
|
||||||
"""
|
|
||||||
Extract resolver function name from lambda or function.
|
|
||||||
|
|
||||||
Most resolvers are lambdas like: lambda pid: function_name(pid)
|
|
||||||
We want to extract the function_name.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get source code of lambda
|
|
||||||
source = inspect.getsource(resolver_func).strip()
|
|
||||||
|
|
||||||
# Pattern: lambda pid: function_name(...)
|
|
||||||
match = re.search(r'lambda\s+\w+:\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(', source)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
# Pattern: direct function reference
|
|
||||||
if hasattr(resolver_func, '__name__'):
|
|
||||||
return resolver_func.__name__
|
|
||||||
|
|
||||||
except (OSError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_data_layer_usage(resolver_name: str) -> Tuple[Optional[str], Optional[str], List[str]]:
|
|
||||||
"""
|
|
||||||
Analyze which data_layer function and tables are used.
|
|
||||||
|
|
||||||
Returns: (data_layer_function, data_layer_module, source_tables)
|
|
||||||
|
|
||||||
This is a heuristic analysis based on naming patterns.
|
|
||||||
"""
|
|
||||||
# Map common resolver patterns to data layer modules
|
|
||||||
data_layer_mapping = {
|
|
||||||
'get_latest_weight': ('get_latest_weight_data', 'body_metrics', ['weight_log']),
|
|
||||||
'get_weight_trend': ('get_weight_trend_data', 'body_metrics', ['weight_log']),
|
|
||||||
'get_latest_bf': ('get_body_composition_data', 'body_metrics', ['caliper_log']),
|
|
||||||
'get_circ_summary': ('get_circumference_summary_data', 'body_metrics', ['circumference_log']),
|
|
||||||
'get_caliper_summary': ('get_body_composition_data', 'body_metrics', ['caliper_log']),
|
|
||||||
|
|
||||||
# Nutrition
|
|
||||||
'get_nutrition_avg': ('get_nutrition_average_data', 'nutrition_metrics', ['nutrition_log']),
|
|
||||||
'get_protein_per_kg': ('get_protein_targets_data', 'nutrition_metrics', ['nutrition_log', 'weight_log']),
|
|
||||||
|
|
||||||
# Activity
|
|
||||||
'get_activity_summary': ('get_activity_summary_data', 'activity_metrics', ['activity_log']),
|
|
||||||
'get_activity_detail': ('get_activity_detail_data', 'activity_metrics', ['activity_log', 'training_types']),
|
|
||||||
'get_training_type_dist': ('get_training_type_distribution_data', 'activity_metrics', ['activity_log', 'training_types']),
|
|
||||||
|
|
||||||
# Sleep
|
|
||||||
'get_sleep_duration': ('get_sleep_duration_data', 'recovery_metrics', ['sleep_log']),
|
|
||||||
'get_sleep_quality': ('get_sleep_quality_data', 'recovery_metrics', ['sleep_log']),
|
|
||||||
|
|
||||||
# Vitals
|
|
||||||
'get_resting_hr': ('get_resting_heart_rate_data', 'health_metrics', ['vitals_baseline']),
|
|
||||||
'get_hrv': ('get_heart_rate_variability_data', 'health_metrics', ['vitals_baseline']),
|
|
||||||
'get_vo2_max': ('get_vo2_max_data', 'health_metrics', ['vitals_baseline']),
|
|
||||||
|
|
||||||
# Goals
|
|
||||||
'_safe_json': (None, None, ['goals', 'focus_area_definitions', 'goal_focus_contributions']),
|
|
||||||
'_safe_str': (None, None, []),
|
|
||||||
'_safe_int': (None, None, []),
|
|
||||||
'_safe_float': (None, None, []),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to find mapping
|
|
||||||
for pattern, (func, module, tables) in data_layer_mapping.items():
|
|
||||||
if pattern in resolver_name:
|
|
||||||
return func, module, tables
|
|
||||||
|
|
||||||
# Default: unknown
|
|
||||||
return None, None, []
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main Extraction ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def extract_metadata_from_placeholder_map(
|
|
||||||
placeholder_map: Dict[str, Any],
|
|
||||||
catalog: Dict[str, List[Dict[str, str]]]
|
|
||||||
) -> Dict[str, PlaceholderMetadata]:
|
|
||||||
"""
|
|
||||||
Extract metadata for all placeholders from PLACEHOLDER_MAP and catalog.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
placeholder_map: The PLACEHOLDER_MAP dict from placeholder_resolver
|
|
||||||
catalog: The catalog from get_placeholder_catalog()
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping key to PlaceholderMetadata
|
|
||||||
"""
|
|
||||||
# Flatten catalog for easy lookup
|
|
||||||
catalog_flat = {}
|
|
||||||
for category, items in catalog.items():
|
|
||||||
for item in items:
|
|
||||||
catalog_flat[item['key']] = {
|
|
||||||
'category': category,
|
|
||||||
'description': item['description']
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata_dict = {}
|
|
||||||
|
|
||||||
for placeholder_full, resolver_func in placeholder_map.items():
|
|
||||||
# Extract key (remove {{ }})
|
|
||||||
key = placeholder_full.replace('{{', '').replace('}}', '')
|
|
||||||
|
|
||||||
# Get catalog info
|
|
||||||
catalog_info = catalog_flat.get(key, {
|
|
||||||
'category': 'Unknown',
|
|
||||||
'description': 'No description available'
|
|
||||||
})
|
|
||||||
|
|
||||||
category = catalog_info['category']
|
|
||||||
description = catalog_info['description']
|
|
||||||
|
|
||||||
# Extract resolver name
|
|
||||||
resolver_name = extract_resolver_name(resolver_func)
|
|
||||||
|
|
||||||
# Infer metadata using heuristics
|
|
||||||
ptype = infer_type_from_key(key, description)
|
|
||||||
time_window = infer_time_window_from_key(key)
|
|
||||||
output_type = infer_output_type_from_key(key)
|
|
||||||
unit = infer_unit_from_key_and_description(key, description)
|
|
||||||
|
|
||||||
# Analyze data layer usage
|
|
||||||
dl_func, dl_module, source_tables = analyze_data_layer_usage(resolver_name)
|
|
||||||
|
|
||||||
# Build source info
|
|
||||||
source = SourceInfo(
|
|
||||||
resolver=resolver_name,
|
|
||||||
module="placeholder_resolver.py",
|
|
||||||
function=dl_func,
|
|
||||||
data_layer_module=dl_module,
|
|
||||||
source_tables=source_tables
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build semantic contract (enhanced description)
|
|
||||||
semantic_contract = build_semantic_contract(key, description, time_window, ptype)
|
|
||||||
|
|
||||||
# Format hint
|
|
||||||
format_hint = build_format_hint(key, unit, output_type)
|
|
||||||
|
|
||||||
# Create metadata
|
|
||||||
metadata = PlaceholderMetadata(
|
|
||||||
key=key,
|
|
||||||
placeholder=placeholder_full,
|
|
||||||
category=category,
|
|
||||||
type=ptype,
|
|
||||||
description=description,
|
|
||||||
semantic_contract=semantic_contract,
|
|
||||||
unit=unit,
|
|
||||||
time_window=time_window,
|
|
||||||
output_type=output_type,
|
|
||||||
format_hint=format_hint,
|
|
||||||
example_output=None, # Will be filled at runtime
|
|
||||||
source=source,
|
|
||||||
dependencies=['profile_id'], # All placeholders depend on profile_id
|
|
||||||
used_by=UsedBy(), # Will be filled by usage analysis
|
|
||||||
version="1.0.0",
|
|
||||||
deprecated=False,
|
|
||||||
known_issues=[],
|
|
||||||
notes=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
metadata_dict[key] = metadata
|
|
||||||
|
|
||||||
return metadata_dict
|
|
||||||
|
|
||||||
|
|
||||||
def build_semantic_contract(key: str, description: str, time_window: TimeWindow, ptype: PlaceholderType) -> str:
|
|
||||||
"""
|
|
||||||
Build detailed semantic contract from available information.
|
|
||||||
"""
|
|
||||||
base = description
|
|
||||||
|
|
||||||
# Add time window info
|
|
||||||
if time_window == TimeWindow.LATEST:
|
|
||||||
base += " (letzter verfügbarer Wert)"
|
|
||||||
elif time_window != TimeWindow.UNKNOWN:
|
|
||||||
base += f" (Zeitfenster: {time_window.value})"
|
|
||||||
|
|
||||||
# Add type info
|
|
||||||
if ptype == PlaceholderType.INTERPRETED:
|
|
||||||
base += " [KI-interpretiert]"
|
|
||||||
elif ptype == PlaceholderType.RAW_DATA:
|
|
||||||
base += " [Strukturierte Rohdaten]"
|
|
||||||
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
def build_format_hint(key: str, unit: Optional[str], output_type: OutputType) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Build format hint based on key, unit, and output type.
|
|
||||||
"""
|
|
||||||
if output_type == OutputType.JSON:
|
|
||||||
return "JSON object"
|
|
||||||
elif output_type == OutputType.MARKDOWN:
|
|
||||||
return "Markdown-formatted text"
|
|
||||||
elif output_type == OutputType.DATE:
|
|
||||||
return "YYYY-MM-DD"
|
|
||||||
elif unit:
|
|
||||||
if output_type == OutputType.NUMBER:
|
|
||||||
return f"12.3 {unit}"
|
|
||||||
elif output_type == OutputType.INTEGER:
|
|
||||||
return f"85 {unit}"
|
|
||||||
else:
|
|
||||||
return f"Wert {unit}"
|
|
||||||
else:
|
|
||||||
if output_type == OutputType.NUMBER:
|
|
||||||
return "12.3"
|
|
||||||
elif output_type == OutputType.INTEGER:
|
|
||||||
return "85"
|
|
||||||
else:
|
|
||||||
return "Text"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Usage Analysis ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def analyze_placeholder_usage(profile_id: str) -> Dict[str, UsedBy]:
|
|
||||||
"""
|
|
||||||
Analyze where each placeholder is used (prompts, pipelines, charts).
|
|
||||||
|
|
||||||
This requires database access to check ai_prompts table.
|
|
||||||
|
|
||||||
Returns dict mapping placeholder key to UsedBy object.
|
|
||||||
"""
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
|
|
||||||
usage_map: Dict[str, UsedBy] = {}
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get all prompts
|
|
||||||
cur.execute("SELECT name, template, stages FROM ai_prompts")
|
|
||||||
prompts = [r2d(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Analyze each prompt
|
|
||||||
for prompt in prompts:
|
|
||||||
# Check template
|
|
||||||
template = prompt.get('template') or ''
|
|
||||||
if template: # Only process if template is not empty/None
|
|
||||||
found_placeholders = re.findall(r'\{\{(\w+)\}\}', template)
|
|
||||||
|
|
||||||
for ph_key in found_placeholders:
|
|
||||||
if ph_key not in usage_map:
|
|
||||||
usage_map[ph_key] = UsedBy()
|
|
||||||
if prompt['name'] not in usage_map[ph_key].prompts:
|
|
||||||
usage_map[ph_key].prompts.append(prompt['name'])
|
|
||||||
|
|
||||||
# Check stages (pipeline prompts)
|
|
||||||
stages = prompt.get('stages')
|
|
||||||
if stages:
|
|
||||||
for stage in stages:
|
|
||||||
for stage_prompt in stage.get('prompts', []):
|
|
||||||
template = stage_prompt.get('template') or ''
|
|
||||||
if not template: # Skip if template is None/empty
|
|
||||||
continue
|
|
||||||
found_placeholders = re.findall(r'\{\{(\w+)\}\}', template)
|
|
||||||
|
|
||||||
for ph_key in found_placeholders:
|
|
||||||
if ph_key not in usage_map:
|
|
||||||
usage_map[ph_key] = UsedBy()
|
|
||||||
if prompt['name'] not in usage_map[ph_key].pipelines:
|
|
||||||
usage_map[ph_key].pipelines.append(prompt['name'])
|
|
||||||
|
|
||||||
return usage_map
|
|
||||||
|
|
||||||
|
|
||||||
# ── Main Entry Point ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_complete_metadata_registry(profile_id: str = None) -> PlaceholderMetadataRegistry:
|
|
||||||
"""
|
|
||||||
Build complete metadata registry by extracting from codebase.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: Optional profile ID for usage analysis
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PlaceholderMetadataRegistry with all metadata
|
|
||||||
"""
|
|
||||||
from placeholder_resolver import PLACEHOLDER_MAP, get_placeholder_catalog
|
|
||||||
|
|
||||||
# Get catalog (use dummy profile if not provided)
|
|
||||||
if not profile_id:
|
|
||||||
# Use first available profile or create dummy
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id FROM profiles LIMIT 1")
|
|
||||||
row = cur.fetchone()
|
|
||||||
profile_id = row['id'] if row else 'dummy'
|
|
||||||
|
|
||||||
catalog = get_placeholder_catalog(profile_id)
|
|
||||||
|
|
||||||
# Extract base metadata
|
|
||||||
metadata_dict = extract_metadata_from_placeholder_map(PLACEHOLDER_MAP, catalog)
|
|
||||||
|
|
||||||
# Analyze usage
|
|
||||||
if profile_id != 'dummy':
|
|
||||||
usage_map = analyze_placeholder_usage(profile_id)
|
|
||||||
for key, used_by in usage_map.items():
|
|
||||||
if key in metadata_dict:
|
|
||||||
metadata_dict[key].used_by = used_by
|
|
||||||
|
|
||||||
# Register all metadata
|
|
||||||
registry = PlaceholderMetadataRegistry()
|
|
||||||
for metadata in metadata_dict.values():
|
|
||||||
try:
|
|
||||||
registry.register(metadata, validate=False) # Don't validate during initial extraction
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Failed to register {metadata.key}: {e}")
|
|
||||||
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Test extraction
|
|
||||||
print("Building metadata registry...")
|
|
||||||
registry = build_complete_metadata_registry()
|
|
||||||
print(f"Extracted metadata for {registry.count()} placeholders")
|
|
||||||
|
|
||||||
# Show sample
|
|
||||||
all_metadata = registry.get_all()
|
|
||||||
if all_metadata:
|
|
||||||
sample_key = list(all_metadata.keys())[0]
|
|
||||||
sample = all_metadata[sample_key]
|
|
||||||
print(f"\nSample metadata for '{sample_key}':")
|
|
||||||
print(sample.to_json())
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,349 +0,0 @@
|
||||||
"""
|
|
||||||
Training Type Profiles - Master Evaluator
|
|
||||||
Comprehensive activity evaluation across all 7 dimensions.
|
|
||||||
|
|
||||||
Issue: #15
|
|
||||||
Date: 2026-03-23
|
|
||||||
"""
|
|
||||||
from typing import Dict, Optional, List
|
|
||||||
from datetime import datetime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from rule_engine import RuleEvaluator, IntensityZoneEvaluator, TrainingEffectsEvaluator
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingProfileEvaluator:
|
|
||||||
"""
|
|
||||||
Master class for comprehensive activity evaluation.
|
|
||||||
|
|
||||||
Evaluates an activity against a training type profile across 7 dimensions:
|
|
||||||
1. Minimum Requirements (Quality Gates)
|
|
||||||
2. Intensity Zones (HR zones)
|
|
||||||
3. Training Effects (Abilities)
|
|
||||||
4. Periodization (Frequency & Recovery)
|
|
||||||
5. Performance Indicators (KPIs)
|
|
||||||
6. Safety (Warnings)
|
|
||||||
7. AI Context
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, parameters_registry: Dict[str, Dict]):
|
|
||||||
"""
|
|
||||||
Initialize evaluator with parameter registry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parameters_registry: Dict mapping parameter_key -> config
|
|
||||||
"""
|
|
||||||
self.parameters_registry = parameters_registry
|
|
||||||
self.rule_evaluator = RuleEvaluator()
|
|
||||||
self.zone_evaluator = IntensityZoneEvaluator()
|
|
||||||
self.effects_evaluator = TrainingEffectsEvaluator()
|
|
||||||
|
|
||||||
def evaluate_activity(
|
|
||||||
self,
|
|
||||||
activity: Dict,
|
|
||||||
training_type_profile: Optional[Dict],
|
|
||||||
context: Optional[Dict] = None
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Complete evaluation of an activity against its training type profile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
activity: Activity data dictionary
|
|
||||||
training_type_profile: Training type profile (JSONB)
|
|
||||||
context: {
|
|
||||||
"user_profile": {...},
|
|
||||||
"recent_activities": [...],
|
|
||||||
"historical_activities": [...]
|
|
||||||
}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"evaluated_at": ISO timestamp,
|
|
||||||
"profile_version": str,
|
|
||||||
"rule_set_results": {
|
|
||||||
"minimum_requirements": {...},
|
|
||||||
"intensity_zones": {...},
|
|
||||||
"training_effects": {...},
|
|
||||||
"periodization": {...},
|
|
||||||
"performance_indicators": {...},
|
|
||||||
"safety": {...}
|
|
||||||
},
|
|
||||||
"overall_score": float (0-1),
|
|
||||||
"quality_label": str,
|
|
||||||
"recommendations": [str],
|
|
||||||
"warnings": [str]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# No profile? Return unvalidated result
|
|
||||||
if not training_type_profile:
|
|
||||||
return self._create_unvalidated_result()
|
|
||||||
|
|
||||||
rule_sets = training_type_profile.get("rule_sets", {})
|
|
||||||
context = context or {}
|
|
||||||
|
|
||||||
results = {
|
|
||||||
"evaluated_at": datetime.now().isoformat(),
|
|
||||||
"profile_version": training_type_profile.get("version", "unknown"),
|
|
||||||
"rule_set_results": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ━━━ 1. MINIMUM REQUIREMENTS ━━━
|
|
||||||
if "minimum_requirements" in rule_sets:
|
|
||||||
results["rule_set_results"]["minimum_requirements"] = \
|
|
||||||
self.rule_evaluator.evaluate_rule_set(
|
|
||||||
rule_sets["minimum_requirements"],
|
|
||||||
activity,
|
|
||||||
self.parameters_registry
|
|
||||||
)
|
|
||||||
|
|
||||||
# ━━━ 2. INTENSITY ZONES ━━━
|
|
||||||
if "intensity_zones" in rule_sets:
|
|
||||||
results["rule_set_results"]["intensity_zones"] = \
|
|
||||||
self.zone_evaluator.evaluate(
|
|
||||||
rule_sets["intensity_zones"],
|
|
||||||
activity,
|
|
||||||
context.get("user_profile", {})
|
|
||||||
)
|
|
||||||
|
|
||||||
# ━━━ 3. TRAINING EFFECTS ━━━
|
|
||||||
if "training_effects" in rule_sets:
|
|
||||||
results["rule_set_results"]["training_effects"] = \
|
|
||||||
self.effects_evaluator.evaluate(
|
|
||||||
rule_sets["training_effects"],
|
|
||||||
activity,
|
|
||||||
results["rule_set_results"].get("intensity_zones")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ━━━ 4. PERIODIZATION ━━━
|
|
||||||
if "periodization" in rule_sets:
|
|
||||||
results["rule_set_results"]["periodization"] = \
|
|
||||||
self._evaluate_periodization(
|
|
||||||
rule_sets["periodization"],
|
|
||||||
activity,
|
|
||||||
context.get("recent_activities", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
# ━━━ 5. PERFORMANCE INDICATORS ━━━
|
|
||||||
if "performance_indicators" in rule_sets:
|
|
||||||
results["rule_set_results"]["performance_indicators"] = \
|
|
||||||
self._evaluate_performance(
|
|
||||||
rule_sets["performance_indicators"],
|
|
||||||
activity,
|
|
||||||
context.get("historical_activities", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
# ━━━ 6. SAFETY WARNINGS ━━━
|
|
||||||
if "safety" in rule_sets:
|
|
||||||
results["rule_set_results"]["safety"] = \
|
|
||||||
self._evaluate_safety(
|
|
||||||
rule_sets["safety"],
|
|
||||||
activity
|
|
||||||
)
|
|
||||||
|
|
||||||
# ━━━ OVERALL SCORE & QUALITY LABEL ━━━
|
|
||||||
overall_score = self._calculate_overall_score(results["rule_set_results"])
|
|
||||||
results["overall_score"] = overall_score
|
|
||||||
results["quality_label"] = self._get_quality_label(overall_score)
|
|
||||||
|
|
||||||
# ━━━ RECOMMENDATIONS & WARNINGS ━━━
|
|
||||||
results["recommendations"] = self._generate_recommendations(results)
|
|
||||||
results["warnings"] = self._collect_warnings(results)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def _create_unvalidated_result(self) -> Dict:
|
|
||||||
"""Creates result for activities without profile."""
|
|
||||||
return {
|
|
||||||
"evaluated_at": datetime.now().isoformat(),
|
|
||||||
"profile_version": None,
|
|
||||||
"rule_set_results": {},
|
|
||||||
"overall_score": None,
|
|
||||||
"quality_label": None,
|
|
||||||
"recommendations": ["Kein Trainingsprofil konfiguriert"],
|
|
||||||
"warnings": []
|
|
||||||
}
|
|
||||||
|
|
||||||
def _evaluate_periodization(
|
|
||||||
self,
|
|
||||||
config: Dict,
|
|
||||||
activity: Dict,
|
|
||||||
recent_activities: List[Dict]
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Evaluates periodization compliance (frequency & recovery).
|
|
||||||
|
|
||||||
Simplified for MVP - full implementation later.
|
|
||||||
"""
|
|
||||||
if not config.get("enabled", False):
|
|
||||||
return {"enabled": False}
|
|
||||||
|
|
||||||
# Basic frequency check
|
|
||||||
training_type_id = activity.get("training_type_id")
|
|
||||||
same_type_this_week = sum(
|
|
||||||
1 for a in recent_activities
|
|
||||||
if a.get("training_type_id") == training_type_id
|
|
||||||
)
|
|
||||||
|
|
||||||
frequency_config = config.get("frequency", {})
|
|
||||||
optimal = frequency_config.get("per_week_optimal", 3)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"weekly_count": same_type_this_week,
|
|
||||||
"optimal_count": optimal,
|
|
||||||
"frequency_status": "optimal" if same_type_this_week <= optimal else "over_optimal",
|
|
||||||
"recovery_adequate": True, # Simplified for MVP
|
|
||||||
"warning": None
|
|
||||||
}
|
|
||||||
|
|
||||||
def _evaluate_performance(
|
|
||||||
self,
|
|
||||||
config: Dict,
|
|
||||||
activity: Dict,
|
|
||||||
historical_activities: List[Dict]
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
Evaluates performance development.
|
|
||||||
|
|
||||||
Simplified for MVP - full implementation later.
|
|
||||||
"""
|
|
||||||
if not config.get("enabled", False):
|
|
||||||
return {"enabled": False}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"trend": "stable", # Simplified
|
|
||||||
"metrics_comparison": {},
|
|
||||||
"benchmark_level": "intermediate"
|
|
||||||
}
|
|
||||||
|
|
||||||
def _evaluate_safety(self, config: Dict, activity: Dict) -> Dict:
|
|
||||||
"""
|
|
||||||
Evaluates safety warnings.
|
|
||||||
"""
|
|
||||||
if not config.get("enabled", False):
|
|
||||||
return {"enabled": False, "warnings": []}
|
|
||||||
|
|
||||||
warnings_config = config.get("warnings", [])
|
|
||||||
triggered_warnings = []
|
|
||||||
|
|
||||||
for warning_rule in warnings_config:
|
|
||||||
param_key = warning_rule.get("parameter")
|
|
||||||
operator = warning_rule.get("operator")
|
|
||||||
threshold = warning_rule.get("value")
|
|
||||||
severity = warning_rule.get("severity", "medium")
|
|
||||||
message = warning_rule.get("message", "")
|
|
||||||
|
|
||||||
actual_value = activity.get(param_key)
|
|
||||||
|
|
||||||
if actual_value is not None:
|
|
||||||
operator_func = RuleEvaluator.OPERATORS.get(operator)
|
|
||||||
if operator_func and operator_func(actual_value, threshold):
|
|
||||||
triggered_warnings.append({
|
|
||||||
"severity": severity,
|
|
||||||
"message": message,
|
|
||||||
"parameter": param_key,
|
|
||||||
"actual_value": actual_value,
|
|
||||||
"threshold": threshold
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"warnings": triggered_warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
def _calculate_overall_score(self, rule_set_results: Dict) -> float:
|
|
||||||
"""
|
|
||||||
Calculates weighted overall score.
|
|
||||||
|
|
||||||
Weights:
|
|
||||||
- Minimum Requirements: 40%
|
|
||||||
- Intensity Zones: 20%
|
|
||||||
- Periodization: 20%
|
|
||||||
- Performance: 10%
|
|
||||||
- Training Effects: 10%
|
|
||||||
"""
|
|
||||||
weights = {
|
|
||||||
"minimum_requirements": 0.4,
|
|
||||||
"intensity_zones": 0.2,
|
|
||||||
"periodization": 0.2,
|
|
||||||
"performance_indicators": 0.1,
|
|
||||||
"training_effects": 0.1
|
|
||||||
}
|
|
||||||
|
|
||||||
total_score = 0.0
|
|
||||||
total_weight = 0.0
|
|
||||||
|
|
||||||
for rule_set_name, weight in weights.items():
|
|
||||||
result = rule_set_results.get(rule_set_name)
|
|
||||||
if result and result.get("enabled"):
|
|
||||||
score = result.get("score", 0.5)
|
|
||||||
|
|
||||||
# Special handling for different result types
|
|
||||||
if rule_set_name == "intensity_zones":
|
|
||||||
score = result.get("duration_quality", 0.5)
|
|
||||||
elif rule_set_name == "periodization":
|
|
||||||
score = 1.0 if result.get("recovery_adequate", False) else 0.5
|
|
||||||
|
|
||||||
total_score += score * weight
|
|
||||||
total_weight += weight
|
|
||||||
|
|
||||||
return round(total_score / total_weight, 2) if total_weight > 0 else 0.5
|
|
||||||
|
|
||||||
def _get_quality_label(self, score: Optional[float]) -> Optional[str]:
|
|
||||||
"""Converts score to quality label."""
|
|
||||||
if score is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if score >= 0.9:
|
|
||||||
return "excellent"
|
|
||||||
elif score >= 0.7:
|
|
||||||
return "good"
|
|
||||||
elif score >= 0.5:
|
|
||||||
return "acceptable"
|
|
||||||
else:
|
|
||||||
return "poor"
|
|
||||||
|
|
||||||
def _generate_recommendations(self, results: Dict) -> List[str]:
|
|
||||||
"""Generates actionable recommendations."""
|
|
||||||
recommendations = []
|
|
||||||
|
|
||||||
# Check minimum requirements
|
|
||||||
min_req = results["rule_set_results"].get("minimum_requirements", {})
|
|
||||||
if min_req.get("enabled") and not min_req.get("passed"):
|
|
||||||
for failed in min_req.get("failed_rules", []):
|
|
||||||
param = failed.get("parameter")
|
|
||||||
actual = failed.get("actual_value")
|
|
||||||
expected = failed.get("expected_value")
|
|
||||||
reason = failed.get("reason", "")
|
|
||||||
symbol = failed.get("operator_symbol", "")
|
|
||||||
|
|
||||||
recommendations.append(
|
|
||||||
f"{param}: {actual} {symbol} {expected} - {reason}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check intensity zones
|
|
||||||
zone_result = results["rule_set_results"].get("intensity_zones", {})
|
|
||||||
if zone_result.get("enabled") and zone_result.get("recommendation"):
|
|
||||||
recommendations.append(zone_result["recommendation"])
|
|
||||||
|
|
||||||
# Default recommendation if excellent
|
|
||||||
if results.get("quality_label") == "excellent" and not recommendations:
|
|
||||||
recommendations.append("Hervorragendes Training! Weiter so.")
|
|
||||||
|
|
||||||
return recommendations
|
|
||||||
|
|
||||||
def _collect_warnings(self, results: Dict) -> List[str]:
|
|
||||||
"""Collects all warnings from safety checks."""
|
|
||||||
safety_result = results["rule_set_results"].get("safety", {})
|
|
||||||
if not safety_result.get("enabled"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
warnings = []
|
|
||||||
for warning in safety_result.get("warnings", []):
|
|
||||||
severity_icon = "🔴" if warning["severity"] == "high" else "⚠️"
|
|
||||||
warnings.append(f"{severity_icon} {warning['message']}")
|
|
||||||
|
|
||||||
return warnings
|
|
||||||
|
|
@ -1,450 +0,0 @@
|
||||||
"""
|
|
||||||
Training Type Profile Templates
|
|
||||||
Pre-configured profiles for common training types.
|
|
||||||
|
|
||||||
Issue: #15
|
|
||||||
Date: 2026-03-23
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# TEMPLATE: LAUFEN (Running) - Ausdauer-fokussiert
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
TEMPLATE_RUNNING = {
|
|
||||||
"version": "1.0",
|
|
||||||
"name": "Laufen (Standard)",
|
|
||||||
"description": "Ausdauerlauf mit Herzfrequenz-Zonen",
|
|
||||||
|
|
||||||
"rule_sets": {
|
|
||||||
"minimum_requirements": {
|
|
||||||
"enabled": True,
|
|
||||||
"pass_strategy": "weighted_score",
|
|
||||||
"pass_threshold": 0.6,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "duration_min",
|
|
||||||
"operator": "gte",
|
|
||||||
"value": 15,
|
|
||||||
"weight": 5,
|
|
||||||
"optional": False,
|
|
||||||
"reason": "Mindestens 15 Minuten für Trainingseffekt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr",
|
|
||||||
"operator": "gte",
|
|
||||||
"value": 100,
|
|
||||||
"weight": 3,
|
|
||||||
"optional": False,
|
|
||||||
"reason": "Puls muss für Ausdauerreiz erhöht sein"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameter": "distance_km",
|
|
||||||
"operator": "gte",
|
|
||||||
"value": 1.0,
|
|
||||||
"weight": 2,
|
|
||||||
"optional": False,
|
|
||||||
"reason": "Mindestens 1 km Distanz"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"intensity_zones": {
|
|
||||||
"enabled": True,
|
|
||||||
"zones": [
|
|
||||||
{
|
|
||||||
"id": "regeneration",
|
|
||||||
"name": "Regeneration",
|
|
||||||
"color": "#4CAF50",
|
|
||||||
"effect": "Aktive Erholung",
|
|
||||||
"target_duration_min": 30,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr_percent",
|
|
||||||
"operator": "between",
|
|
||||||
"value": [50, 60]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "grundlagenausdauer",
|
|
||||||
"name": "Grundlagenausdauer",
|
|
||||||
"color": "#2196F3",
|
|
||||||
"effect": "Fettverbrennung, aerobe Basis",
|
|
||||||
"target_duration_min": 45,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr_percent",
|
|
||||||
"operator": "between",
|
|
||||||
"value": [60, 70]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "entwicklungsbereich",
|
|
||||||
"name": "Entwicklungsbereich",
|
|
||||||
"color": "#FF9800",
|
|
||||||
"effect": "VO2max-Training, Laktattoleranz",
|
|
||||||
"target_duration_min": 30,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr_percent",
|
|
||||||
"operator": "between",
|
|
||||||
"value": [70, 80]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "schwellentraining",
|
|
||||||
"name": "Schwellentraining",
|
|
||||||
"color": "#F44336",
|
|
||||||
"effect": "Anaerobe Schwelle, Wettkampftempo",
|
|
||||||
"target_duration_min": 20,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr_percent",
|
|
||||||
"operator": "between",
|
|
||||||
"value": [80, 90]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"training_effects": {
|
|
||||||
"enabled": True,
|
|
||||||
"default_effects": {
|
|
||||||
"primary_abilities": [
|
|
||||||
{
|
|
||||||
"category": "konditionell",
|
|
||||||
"ability": "ausdauer",
|
|
||||||
"intensity": 5
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"secondary_abilities": [
|
|
||||||
{
|
|
||||||
"category": "konditionell",
|
|
||||||
"ability": "schnelligkeit",
|
|
||||||
"intensity": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "koordinativ",
|
|
||||||
"ability": "rhythmus",
|
|
||||||
"intensity": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "psychisch",
|
|
||||||
"ability": "willenskraft",
|
|
||||||
"intensity": 4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metabolic_focus": ["aerobic", "fat_oxidation"],
|
|
||||||
"muscle_groups": ["legs", "core", "cardiovascular"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"periodization": {
|
|
||||||
"enabled": True,
|
|
||||||
"frequency": {
|
|
||||||
"per_week_optimal": 3,
|
|
||||||
"per_week_max": 5
|
|
||||||
},
|
|
||||||
"recovery": {
|
|
||||||
"min_hours_between": 24
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"performance_indicators": {
|
|
||||||
"enabled": False
|
|
||||||
},
|
|
||||||
|
|
||||||
"safety": {
|
|
||||||
"enabled": True,
|
|
||||||
"warnings": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr_percent",
|
|
||||||
"operator": "gt",
|
|
||||||
"value": 95,
|
|
||||||
"severity": "high",
|
|
||||||
"message": "Herzfrequenz zu hoch - Überbelastungsrisiko"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameter": "duration_min",
|
|
||||||
"operator": "gt",
|
|
||||||
"value": 180,
|
|
||||||
"severity": "medium",
|
|
||||||
"message": "Sehr lange Einheit - achte auf Regeneration"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# TEMPLATE: MEDITATION - Mental-fokussiert (≤ statt ≥ bei HR!)
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
TEMPLATE_MEDITATION = {
|
|
||||||
"version": "1.0",
|
|
||||||
"name": "Meditation (Standard)",
|
|
||||||
"description": "Mentales Training mit niedrigem Puls",
|
|
||||||
|
|
||||||
"rule_sets": {
|
|
||||||
"minimum_requirements": {
|
|
||||||
"enabled": True,
|
|
||||||
"pass_strategy": "weighted_score",
|
|
||||||
"pass_threshold": 0.6,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "duration_min",
|
|
||||||
"operator": "gte",
|
|
||||||
"value": 5,
|
|
||||||
"weight": 5,
|
|
||||||
"optional": False,
|
|
||||||
"reason": "Mindestens 5 Minuten für Entspannungseffekt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr",
|
|
||||||
"operator": "lte",
|
|
||||||
"value": 80,
|
|
||||||
"weight": 4,
|
|
||||||
"optional": False,
|
|
||||||
"reason": "Niedriger Puls zeigt Entspannung an"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"intensity_zones": {
|
|
||||||
"enabled": True,
|
|
||||||
"zones": [
|
|
||||||
{
|
|
||||||
"id": "deep_relaxation",
|
|
||||||
"name": "Tiefenentspannung",
|
|
||||||
"color": "#4CAF50",
|
|
||||||
"effect": "Parasympathikus-Aktivierung",
|
|
||||||
"target_duration_min": 10,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr_percent",
|
|
||||||
"operator": "between",
|
|
||||||
"value": [35, 45]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "light_meditation",
|
|
||||||
"name": "Leichte Meditation",
|
|
||||||
"color": "#2196F3",
|
|
||||||
"effect": "Achtsamkeit, Fokus",
|
|
||||||
"target_duration_min": 15,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr_percent",
|
|
||||||
"operator": "between",
|
|
||||||
"value": [45, 55]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"training_effects": {
|
|
||||||
"enabled": True,
|
|
||||||
"default_effects": {
|
|
||||||
"primary_abilities": [
|
|
||||||
{
|
|
||||||
"category": "kognitiv",
|
|
||||||
"ability": "konzentration",
|
|
||||||
"intensity": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "psychisch",
|
|
||||||
"ability": "stressresistenz",
|
|
||||||
"intensity": 5
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"secondary_abilities": [
|
|
||||||
{
|
|
||||||
"category": "kognitiv",
|
|
||||||
"ability": "wahrnehmung",
|
|
||||||
"intensity": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "psychisch",
|
|
||||||
"ability": "selbstvertrauen",
|
|
||||||
"intensity": 3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metabolic_focus": ["parasympathetic_activation"],
|
|
||||||
"muscle_groups": []
|
|
||||||
},
|
|
||||||
|
|
||||||
"periodization": {
|
|
||||||
"enabled": True,
|
|
||||||
"frequency": {
|
|
||||||
"per_week_optimal": 5,
|
|
||||||
"per_week_max": 7
|
|
||||||
},
|
|
||||||
"recovery": {
|
|
||||||
"min_hours_between": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"performance_indicators": {
|
|
||||||
"enabled": False
|
|
||||||
},
|
|
||||||
|
|
||||||
"safety": {
|
|
||||||
"enabled": True,
|
|
||||||
"warnings": [
|
|
||||||
{
|
|
||||||
"parameter": "avg_hr",
|
|
||||||
"operator": "gt",
|
|
||||||
"value": 100,
|
|
||||||
"severity": "medium",
|
|
||||||
"message": "Herzfrequenz zu hoch für Meditation - bist du wirklich entspannt?"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# TEMPLATE: KRAFTTRAINING - Kraft-fokussiert
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
TEMPLATE_STRENGTH = {
|
|
||||||
"version": "1.0",
|
|
||||||
"name": "Krafttraining (Standard)",
|
|
||||||
"description": "Krafttraining mit moderater Herzfrequenz",
|
|
||||||
|
|
||||||
"rule_sets": {
|
|
||||||
"minimum_requirements": {
|
|
||||||
"enabled": True,
|
|
||||||
"pass_strategy": "weighted_score",
|
|
||||||
"pass_threshold": 0.5,
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"parameter": "duration_min",
|
|
||||||
"operator": "gte",
|
|
||||||
"value": 20,
|
|
||||||
"weight": 5,
|
|
||||||
"optional": False,
|
|
||||||
"reason": "Mindestens 20 Minuten für Muskelreiz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameter": "kcal_active",
|
|
||||||
"operator": "gte",
|
|
||||||
"value": 100,
|
|
||||||
"weight": 2,
|
|
||||||
"optional": True,
|
|
||||||
"reason": "Mindest-Kalorienverbrauch"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"intensity_zones": {
|
|
||||||
"enabled": False
|
|
||||||
},
|
|
||||||
|
|
||||||
"training_effects": {
|
|
||||||
"enabled": True,
|
|
||||||
"default_effects": {
|
|
||||||
"primary_abilities": [
|
|
||||||
{
|
|
||||||
"category": "konditionell",
|
|
||||||
"ability": "kraft",
|
|
||||||
"intensity": 5
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"secondary_abilities": [
|
|
||||||
{
|
|
||||||
"category": "koordinativ",
|
|
||||||
"ability": "differenzierung",
|
|
||||||
"intensity": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "psychisch",
|
|
||||||
"ability": "willenskraft",
|
|
||||||
"intensity": 4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metabolic_focus": ["anaerobic", "muscle_growth"],
|
|
||||||
"muscle_groups": ["full_body"]
|
|
||||||
},
|
|
||||||
|
|
||||||
"periodization": {
|
|
||||||
"enabled": True,
|
|
||||||
"frequency": {
|
|
||||||
"per_week_optimal": 3,
|
|
||||||
"per_week_max": 5
|
|
||||||
},
|
|
||||||
"recovery": {
|
|
||||||
"min_hours_between": 48
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"performance_indicators": {
|
|
||||||
"enabled": False
|
|
||||||
},
|
|
||||||
|
|
||||||
"safety": {
|
|
||||||
"enabled": True,
|
|
||||||
"warnings": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# TEMPLATE REGISTRY
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
TEMPLATES = {
|
|
||||||
"running": {
|
|
||||||
"name_de": "Laufen",
|
|
||||||
"name_en": "Running",
|
|
||||||
"icon": "🏃",
|
|
||||||
"categories": ["cardio", "running"],
|
|
||||||
"template": TEMPLATE_RUNNING
|
|
||||||
},
|
|
||||||
"meditation": {
|
|
||||||
"name_de": "Meditation",
|
|
||||||
"name_en": "Meditation",
|
|
||||||
"icon": "🧘",
|
|
||||||
"categories": ["geist", "meditation"],
|
|
||||||
"template": TEMPLATE_MEDITATION
|
|
||||||
},
|
|
||||||
"strength": {
|
|
||||||
"name_de": "Krafttraining",
|
|
||||||
"name_en": "Strength Training",
|
|
||||||
"icon": "💪",
|
|
||||||
"categories": ["kraft", "krafttraining"],
|
|
||||||
"template": TEMPLATE_STRENGTH
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_template(template_key: str) -> dict:
|
|
||||||
"""Get profile template by key."""
|
|
||||||
template_info = TEMPLATES.get(template_key)
|
|
||||||
if not template_info:
|
|
||||||
return None
|
|
||||||
return template_info["template"]
|
|
||||||
|
|
||||||
|
|
||||||
def list_templates() -> list:
|
|
||||||
"""List all available templates."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"key": key,
|
|
||||||
"name_de": info["name_de"],
|
|
||||||
"name_en": info["name_en"],
|
|
||||||
"icon": info["icon"],
|
|
||||||
"categories": info["categories"]
|
|
||||||
}
|
|
||||||
for key, info in TEMPLATES.items()
|
|
||||||
]
|
|
||||||
|
|
@ -1,526 +0,0 @@
|
||||||
"""
|
|
||||||
Unified Prompt Executor (Issue #28 Phase 2)
|
|
||||||
|
|
||||||
Executes both base and pipeline-type prompts with:
|
|
||||||
- Dynamic placeholder resolution
|
|
||||||
- JSON output validation
|
|
||||||
- Multi-stage parallel execution
|
|
||||||
- Reference and inline prompt support
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: Optional[Dict] = None, catalog: Optional[Dict] = None) -> str:
|
|
||||||
"""
|
|
||||||
Replace {{placeholder}} with values from variables dict.
|
|
||||||
|
|
||||||
Supports modifiers:
|
|
||||||
- {{key|d}} - Include description in parentheses (requires catalog)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template: String with {{key}} or {{key|modifiers}} placeholders
|
|
||||||
variables: Dict of key -> value mappings
|
|
||||||
debug_info: Optional dict to collect debug information
|
|
||||||
catalog: Optional placeholder catalog for descriptions (from get_placeholder_catalog)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Template with placeholders replaced
|
|
||||||
"""
|
|
||||||
resolved = {}
|
|
||||||
unresolved = []
|
|
||||||
|
|
||||||
def replacer(match):
|
|
||||||
full_placeholder = match.group(1).strip()
|
|
||||||
|
|
||||||
# Parse key and modifiers (e.g., "weight_aktuell|d" -> key="weight_aktuell", modifiers="d")
|
|
||||||
parts = full_placeholder.split('|')
|
|
||||||
key = parts[0].strip()
|
|
||||||
modifiers = parts[1].strip() if len(parts) > 1 else ''
|
|
||||||
|
|
||||||
if key in variables:
|
|
||||||
value = variables[key]
|
|
||||||
# Convert dict/list to JSON string
|
|
||||||
if isinstance(value, (dict, list)):
|
|
||||||
resolved_value = json.dumps(value, ensure_ascii=False)
|
|
||||||
else:
|
|
||||||
resolved_value = str(value)
|
|
||||||
|
|
||||||
# Apply modifiers
|
|
||||||
if 'd' in modifiers:
|
|
||||||
if catalog:
|
|
||||||
# Add description from catalog
|
|
||||||
description = None
|
|
||||||
for cat_items in catalog.values():
|
|
||||||
matching = [item for item in cat_items if item['key'] == key]
|
|
||||||
if matching:
|
|
||||||
description = matching[0].get('description', '')
|
|
||||||
break
|
|
||||||
|
|
||||||
if description:
|
|
||||||
resolved_value = f"{resolved_value} ({description})"
|
|
||||||
else:
|
|
||||||
# Catalog not available - log warning in debug
|
|
||||||
if debug_info is not None:
|
|
||||||
if 'warnings' not in debug_info:
|
|
||||||
debug_info['warnings'] = []
|
|
||||||
debug_info['warnings'].append(f"Modifier |d used but catalog not available for {key}")
|
|
||||||
|
|
||||||
# Track resolution for debug
|
|
||||||
if debug_info is not None:
|
|
||||||
resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '')
|
|
||||||
|
|
||||||
return resolved_value
|
|
||||||
else:
|
|
||||||
# Keep placeholder if no value found
|
|
||||||
if debug_info is not None:
|
|
||||||
unresolved.append(key)
|
|
||||||
return match.group(0)
|
|
||||||
|
|
||||||
result = re.sub(r'\{\{([^}]+)\}\}', replacer, template)
|
|
||||||
|
|
||||||
# Store debug info
|
|
||||||
if debug_info is not None:
|
|
||||||
debug_info['resolved_placeholders'] = resolved
|
|
||||||
debug_info['unresolved_placeholders'] = unresolved
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def validate_json_output(output: str, schema: Optional[Dict] = None, debug_info: Optional[Dict] = None) -> Dict:
|
|
||||||
"""
|
|
||||||
Validate that output is valid JSON.
|
|
||||||
|
|
||||||
Unwraps Markdown-wrapped JSON (```json ... ```) if present.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
output: String to validate
|
|
||||||
schema: Optional JSON schema to validate against (TODO: jsonschema library)
|
|
||||||
debug_info: Optional dict to attach to error for debugging
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed JSON dict
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If output is not valid JSON (with debug info attached)
|
|
||||||
"""
|
|
||||||
# Try to unwrap Markdown code blocks (common AI pattern)
|
|
||||||
unwrapped = output.strip()
|
|
||||||
if unwrapped.startswith('```json'):
|
|
||||||
# Extract content between ```json and ```
|
|
||||||
lines = unwrapped.split('\n')
|
|
||||||
if len(lines) > 2 and lines[-1].strip() == '```':
|
|
||||||
unwrapped = '\n'.join(lines[1:-1])
|
|
||||||
elif unwrapped.startswith('```'):
|
|
||||||
# Generic code block
|
|
||||||
lines = unwrapped.split('\n')
|
|
||||||
if len(lines) > 2 and lines[-1].strip() == '```':
|
|
||||||
unwrapped = '\n'.join(lines[1:-1])
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(unwrapped)
|
|
||||||
# TODO: Add jsonschema validation if schema provided
|
|
||||||
return parsed
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
error_detail = {
|
|
||||||
"error": f"AI returned invalid JSON: {str(e)}",
|
|
||||||
"raw_output": output[:500] + ('...' if len(output) > 500 else ''),
|
|
||||||
"unwrapped": unwrapped[:500] if unwrapped != output else None,
|
|
||||||
"output_length": len(output)
|
|
||||||
}
|
|
||||||
if debug_info:
|
|
||||||
error_detail["debug"] = debug_info
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=error_detail
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_prompt(
|
|
||||||
prompt_slug: str,
|
|
||||||
variables: Dict[str, Any],
|
|
||||||
openrouter_call_func,
|
|
||||||
enable_debug: bool = False
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Execute a single prompt (base or pipeline type).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt_slug: Slug of prompt to execute
|
|
||||||
variables: Dict of variables for placeholder replacement
|
|
||||||
openrouter_call_func: Async function(prompt_text) -> response_text
|
|
||||||
enable_debug: If True, include debug information in response
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with execution results:
|
|
||||||
{
|
|
||||||
"type": "base" | "pipeline",
|
|
||||||
"slug": "...",
|
|
||||||
"output": "..." | {...}, # String or parsed JSON
|
|
||||||
"stages": [...] # Only for pipeline type
|
|
||||||
"debug": {...} # Only if enable_debug=True
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# Load prompt from database
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT * FROM ai_prompts
|
|
||||||
WHERE slug = %s AND active = true""",
|
|
||||||
(prompt_slug,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, f"Prompt nicht gefunden: {prompt_slug}")
|
|
||||||
|
|
||||||
prompt = r2d(row)
|
|
||||||
|
|
||||||
prompt_type = prompt.get('type', 'pipeline')
|
|
||||||
|
|
||||||
# Get catalog from variables if available (passed from execute_prompt_with_data)
|
|
||||||
catalog = variables.pop('_catalog', None) if '_catalog' in variables else None
|
|
||||||
|
|
||||||
if prompt_type == 'base':
|
|
||||||
# Base prompt: single execution with template
|
|
||||||
return await execute_base_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog)
|
|
||||||
|
|
||||||
elif prompt_type == 'pipeline':
|
|
||||||
# Pipeline prompt: multi-stage execution
|
|
||||||
return await execute_pipeline_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise HTTPException(400, f"Unknown prompt type: {prompt_type}")
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_base_prompt(
|
|
||||||
prompt: Dict,
|
|
||||||
variables: Dict[str, Any],
|
|
||||||
openrouter_call_func,
|
|
||||||
enable_debug: bool = False,
|
|
||||||
catalog: Optional[Dict] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Execute a base-type prompt (single template)."""
|
|
||||||
template = prompt.get('template')
|
|
||||||
if not template:
|
|
||||||
raise HTTPException(400, f"Base prompt missing template: {prompt['slug']}")
|
|
||||||
|
|
||||||
debug_info = {} if enable_debug else None
|
|
||||||
|
|
||||||
# Resolve placeholders (with optional catalog for |d modifier)
|
|
||||||
prompt_text = resolve_placeholders(template, variables, debug_info, catalog)
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
debug_info['template'] = template
|
|
||||||
debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
|
|
||||||
debug_info['available_variables'] = list(variables.keys())
|
|
||||||
|
|
||||||
# Call AI
|
|
||||||
response = await openrouter_call_func(prompt_text)
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
debug_info['ai_response_length'] = len(response)
|
|
||||||
debug_info['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '')
|
|
||||||
|
|
||||||
# Validate JSON if required
|
|
||||||
output_format = prompt.get('output_format', 'text')
|
|
||||||
if output_format == 'json':
|
|
||||||
output = validate_json_output(response, prompt.get('output_schema'), debug_info if enable_debug else None)
|
|
||||||
else:
|
|
||||||
output = response
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"type": "base",
|
|
||||||
"slug": prompt['slug'],
|
|
||||||
"output": output,
|
|
||||||
"output_format": output_format
|
|
||||||
}
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
result['debug'] = debug_info
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_pipeline_prompt(
|
|
||||||
prompt: Dict,
|
|
||||||
variables: Dict[str, Any],
|
|
||||||
openrouter_call_func,
|
|
||||||
enable_debug: bool = False,
|
|
||||||
catalog: Optional[Dict] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Execute a pipeline-type prompt (multi-stage).
|
|
||||||
|
|
||||||
Each stage's results are added to variables for next stage.
|
|
||||||
"""
|
|
||||||
stages = prompt.get('stages')
|
|
||||||
if not stages:
|
|
||||||
raise HTTPException(400, f"Pipeline prompt missing stages: {prompt['slug']}")
|
|
||||||
|
|
||||||
# Parse stages if stored as JSON string
|
|
||||||
if isinstance(stages, str):
|
|
||||||
stages = json.loads(stages)
|
|
||||||
|
|
||||||
stage_results = []
|
|
||||||
context_vars = variables.copy()
|
|
||||||
pipeline_debug = [] if enable_debug else None
|
|
||||||
|
|
||||||
# Execute stages in order
|
|
||||||
for stage_def in sorted(stages, key=lambda s: s['stage']):
|
|
||||||
stage_num = stage_def['stage']
|
|
||||||
stage_prompts = stage_def.get('prompts', [])
|
|
||||||
|
|
||||||
if not stage_prompts:
|
|
||||||
continue
|
|
||||||
|
|
||||||
stage_debug = {} if enable_debug else None
|
|
||||||
if enable_debug:
|
|
||||||
stage_debug['stage'] = stage_num
|
|
||||||
stage_debug['available_variables'] = list(context_vars.keys())
|
|
||||||
stage_debug['prompts'] = []
|
|
||||||
|
|
||||||
# Execute all prompts in this stage (parallel concept, sequential impl for now)
|
|
||||||
stage_outputs = {}
|
|
||||||
|
|
||||||
for prompt_def in stage_prompts:
|
|
||||||
source = prompt_def.get('source')
|
|
||||||
output_key = prompt_def.get('output_key', f'stage{stage_num}')
|
|
||||||
output_format = prompt_def.get('output_format', 'text')
|
|
||||||
|
|
||||||
prompt_debug = {} if enable_debug else None
|
|
||||||
|
|
||||||
if source == 'reference':
|
|
||||||
# Reference to another prompt
|
|
||||||
ref_slug = prompt_def.get('slug')
|
|
||||||
if not ref_slug:
|
|
||||||
raise HTTPException(400, f"Reference prompt missing slug in stage {stage_num}")
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
prompt_debug['source'] = 'reference'
|
|
||||||
prompt_debug['ref_slug'] = ref_slug
|
|
||||||
|
|
||||||
# Load referenced prompt
|
|
||||||
result = await execute_prompt(ref_slug, context_vars, openrouter_call_func, enable_debug)
|
|
||||||
output = result['output']
|
|
||||||
|
|
||||||
if enable_debug and 'debug' in result:
|
|
||||||
prompt_debug['ref_debug'] = result['debug']
|
|
||||||
|
|
||||||
elif source == 'inline':
|
|
||||||
# Inline template
|
|
||||||
template = prompt_def.get('template')
|
|
||||||
if not template:
|
|
||||||
raise HTTPException(400, f"Inline prompt missing template in stage {stage_num}")
|
|
||||||
|
|
||||||
placeholder_debug = {} if enable_debug else None
|
|
||||||
prompt_text = resolve_placeholders(template, context_vars, placeholder_debug, catalog)
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
prompt_debug['source'] = 'inline'
|
|
||||||
prompt_debug['template'] = template
|
|
||||||
prompt_debug['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
|
|
||||||
prompt_debug.update(placeholder_debug)
|
|
||||||
|
|
||||||
response = await openrouter_call_func(prompt_text)
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
prompt_debug['ai_response_length'] = len(response)
|
|
||||||
prompt_debug['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '')
|
|
||||||
|
|
||||||
# Validate JSON if required
|
|
||||||
if output_format == 'json':
|
|
||||||
output = validate_json_output(response, prompt_def.get('output_schema'), prompt_debug if enable_debug else None)
|
|
||||||
else:
|
|
||||||
output = response
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise HTTPException(400, f"Unknown prompt source: {source}")
|
|
||||||
|
|
||||||
# Store output with key
|
|
||||||
stage_outputs[output_key] = output
|
|
||||||
|
|
||||||
# Add to context for next stage
|
|
||||||
context_var_key = f'stage_{stage_num}_{output_key}'
|
|
||||||
context_vars[context_var_key] = output
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
prompt_debug['output_key'] = output_key
|
|
||||||
prompt_debug['context_var_key'] = context_var_key
|
|
||||||
stage_debug['prompts'].append(prompt_debug)
|
|
||||||
|
|
||||||
stage_results.append({
|
|
||||||
"stage": stage_num,
|
|
||||||
"outputs": stage_outputs
|
|
||||||
})
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
stage_debug['output'] = stage_outputs # Add outputs to debug info for value table
|
|
||||||
pipeline_debug.append(stage_debug)
|
|
||||||
|
|
||||||
# Final output is last stage's first output
|
|
||||||
final_output = stage_results[-1]['outputs'] if stage_results else {}
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"type": "pipeline",
|
|
||||||
"slug": prompt['slug'],
|
|
||||||
"stages": stage_results,
|
|
||||||
"output": final_output,
|
|
||||||
"output_format": prompt.get('output_format', 'text')
|
|
||||||
}
|
|
||||||
|
|
||||||
if enable_debug:
|
|
||||||
result['debug'] = {
|
|
||||||
'initial_variables': list(variables.keys()),
|
|
||||||
'stages': pipeline_debug
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_prompt_with_data(
|
|
||||||
prompt_slug: str,
|
|
||||||
profile_id: str,
|
|
||||||
modules: Optional[Dict[str, bool]] = None,
|
|
||||||
timeframes: Optional[Dict[str, int]] = None,
|
|
||||||
openrouter_call_func = None,
|
|
||||||
enable_debug: bool = False
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Execute prompt with data loaded from database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt_slug: Slug of prompt to execute
|
|
||||||
profile_id: User profile ID
|
|
||||||
modules: Dict of module -> enabled (e.g., {"körper": true})
|
|
||||||
timeframes: Dict of module -> days (e.g., {"körper": 30})
|
|
||||||
openrouter_call_func: Async function for AI calls
|
|
||||||
enable_debug: If True, include debug information in response
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Execution result dict
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog
|
|
||||||
|
|
||||||
# Build variables from data modules
|
|
||||||
variables = {
|
|
||||||
'profile_id': profile_id,
|
|
||||||
'today': datetime.now().strftime('%Y-%m-%d')
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load placeholder catalog for |d modifier support
|
|
||||||
try:
|
|
||||||
catalog = get_placeholder_catalog(profile_id)
|
|
||||||
except Exception as e:
|
|
||||||
catalog = None
|
|
||||||
print(f"Warning: Could not load placeholder catalog: {e}")
|
|
||||||
|
|
||||||
variables['_catalog'] = catalog # Will be popped in execute_prompt (can be None)
|
|
||||||
|
|
||||||
# Add PROCESSED placeholders (name, weight_trend, caliper_summary, etc.)
|
|
||||||
# This makes old-style prompts work with the new executor
|
|
||||||
try:
|
|
||||||
processed_placeholders = get_placeholder_example_values(profile_id)
|
|
||||||
# Remove {{ }} from keys (placeholder_resolver returns them with wrappers)
|
|
||||||
cleaned_placeholders = {
|
|
||||||
key.replace('{{', '').replace('}}', ''): value
|
|
||||||
for key, value in processed_placeholders.items()
|
|
||||||
}
|
|
||||||
variables.update(cleaned_placeholders)
|
|
||||||
except Exception as e:
|
|
||||||
# Continue even if placeholder resolution fails
|
|
||||||
if enable_debug:
|
|
||||||
variables['_placeholder_error'] = str(e)
|
|
||||||
|
|
||||||
# Load data for enabled modules
|
|
||||||
if modules:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Weight data
|
|
||||||
if modules.get('körper'):
|
|
||||||
days = timeframes.get('körper', 30)
|
|
||||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, weight FROM weight_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC""",
|
|
||||||
(profile_id, since)
|
|
||||||
)
|
|
||||||
variables['weight_data'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Nutrition data
|
|
||||||
if modules.get('ernährung'):
|
|
||||||
days = timeframes.get('ernährung', 30)
|
|
||||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, kcal, protein_g, fat_g, carbs_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC""",
|
|
||||||
(profile_id, since)
|
|
||||||
)
|
|
||||||
variables['nutrition_data'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Activity data
|
|
||||||
if modules.get('training'):
|
|
||||||
days = timeframes.get('training', 14)
|
|
||||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, activity_type, duration_min, kcal_active, hr_avg
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC""",
|
|
||||||
(profile_id, since)
|
|
||||||
)
|
|
||||||
variables['activity_data'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Sleep data
|
|
||||||
if modules.get('schlaf'):
|
|
||||||
days = timeframes.get('schlaf', 14)
|
|
||||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, sleep_segments, source
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC""",
|
|
||||||
(profile_id, since)
|
|
||||||
)
|
|
||||||
variables['sleep_data'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Vitals data
|
|
||||||
if modules.get('vitalwerte'):
|
|
||||||
days = timeframes.get('vitalwerte', 7)
|
|
||||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Baseline vitals
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC""",
|
|
||||||
(profile_id, since)
|
|
||||||
)
|
|
||||||
variables['vitals_baseline'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Blood pressure
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT measured_at, systolic, diastolic, pulse
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id = %s AND measured_at >= %s
|
|
||||||
ORDER BY measured_at DESC""",
|
|
||||||
(profile_id, since + ' 00:00:00')
|
|
||||||
)
|
|
||||||
variables['blood_pressure'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Mental/Goals (no timeframe, just current state)
|
|
||||||
if modules.get('mentales') or modules.get('ziele'):
|
|
||||||
# TODO: Add mental state / goals data when implemented
|
|
||||||
variables['goals_data'] = []
|
|
||||||
|
|
||||||
# Execute prompt
|
|
||||||
return await execute_prompt(prompt_slug, variables, openrouter_call_func, enable_debug)
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""
|
|
||||||
Quality Filter Helper - Data Access Layer
|
|
||||||
|
|
||||||
Provides consistent quality filtering across all activity queries.
|
|
||||||
Issue: #31
|
|
||||||
"""
|
|
||||||
from typing import Optional, Dict
|
|
||||||
|
|
||||||
|
|
||||||
def get_quality_filter_sql(profile: Dict, table_alias: str = "") -> str:
|
|
||||||
"""
|
|
||||||
Returns SQL WHERE clause fragment for quality filtering.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: User profile dict with quality_filter_level
|
|
||||||
table_alias: Optional table alias (e.g., "a." for "a.quality_label")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SQL fragment (e.g., "AND quality_label IN (...)") or empty string
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> get_quality_filter_sql({'quality_filter_level': 'all'})
|
|
||||||
''
|
|
||||||
>>> get_quality_filter_sql({'quality_filter_level': 'quality'})
|
|
||||||
"AND quality_label IN ('excellent', 'good', 'acceptable')"
|
|
||||||
>>> get_quality_filter_sql({'quality_filter_level': 'excellent'}, 'a.')
|
|
||||||
"AND a.quality_label = 'excellent'"
|
|
||||||
"""
|
|
||||||
level = profile.get('quality_filter_level', 'all')
|
|
||||||
prefix = table_alias if table_alias else ""
|
|
||||||
|
|
||||||
if level == 'all':
|
|
||||||
return '' # No filter
|
|
||||||
elif level == 'quality':
|
|
||||||
return f"AND {prefix}quality_label IN ('excellent', 'good', 'acceptable')"
|
|
||||||
elif level == 'very_good':
|
|
||||||
return f"AND {prefix}quality_label IN ('excellent', 'good')"
|
|
||||||
elif level == 'excellent':
|
|
||||||
return f"AND {prefix}quality_label = 'excellent'"
|
|
||||||
else:
|
|
||||||
# Unknown level → no filter (safe fallback)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
def get_quality_filter_tuple(profile: Dict) -> tuple:
|
|
||||||
"""
|
|
||||||
Returns tuple of allowed quality labels for Python filtering.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: User profile dict with quality_filter_level
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of allowed quality labels or None (no filter)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> get_quality_filter_tuple({'quality_filter_level': 'all'})
|
|
||||||
None
|
|
||||||
>>> get_quality_filter_tuple({'quality_filter_level': 'quality'})
|
|
||||||
('excellent', 'good', 'acceptable')
|
|
||||||
"""
|
|
||||||
level = profile.get('quality_filter_level', 'all')
|
|
||||||
|
|
||||||
if level == 'all':
|
|
||||||
return None # No filter
|
|
||||||
elif level == 'quality':
|
|
||||||
return ('excellent', 'good', 'acceptable')
|
|
||||||
elif level == 'very_good':
|
|
||||||
return ('excellent', 'good')
|
|
||||||
elif level == 'excellent':
|
|
||||||
return ('excellent',)
|
|
||||||
else:
|
|
||||||
return None # Unknown level → no filter
|
|
||||||
|
|
||||||
|
|
||||||
def filter_activities_by_quality(activities: list, profile: Dict) -> list:
|
|
||||||
"""
|
|
||||||
Filters a list of activity dicts by quality_label.
|
|
||||||
|
|
||||||
Useful for post-query filtering (e.g., when data already loaded).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
activities: List of activity dicts with quality_label field
|
|
||||||
profile: User profile dict with quality_filter_level
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filtered list of activities
|
|
||||||
"""
|
|
||||||
allowed_labels = get_quality_filter_tuple(profile)
|
|
||||||
|
|
||||||
if allowed_labels is None:
|
|
||||||
return activities # No filter
|
|
||||||
|
|
||||||
return [
|
|
||||||
act for act in activities
|
|
||||||
if act.get('quality_label') in allowed_labels
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Constants for frontend/documentation
|
|
||||||
QUALITY_LEVELS = {
|
|
||||||
'all': {
|
|
||||||
'label': 'Alle',
|
|
||||||
'icon': '📊',
|
|
||||||
'description': 'Alle Activities (kein Filter)',
|
|
||||||
'includes': None
|
|
||||||
},
|
|
||||||
'quality': {
|
|
||||||
'label': 'Hochwertig',
|
|
||||||
'icon': '✓',
|
|
||||||
'description': 'Hochwertige Activities',
|
|
||||||
'includes': ['excellent', 'good', 'acceptable']
|
|
||||||
},
|
|
||||||
'very_good': {
|
|
||||||
'label': 'Sehr gut',
|
|
||||||
'icon': '✓✓',
|
|
||||||
'description': 'Sehr gute Activities',
|
|
||||||
'includes': ['excellent', 'good']
|
|
||||||
},
|
|
||||||
'excellent': {
|
|
||||||
'label': 'Exzellent',
|
|
||||||
'icon': '⭐',
|
|
||||||
'description': 'Nur exzellente Activities',
|
|
||||||
'includes': ['excellent']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,4 +8,3 @@ pydantic==2.7.1
|
||||||
bcrypt==4.1.3
|
bcrypt==4.1.3
|
||||||
slowapi==0.1.9
|
slowapi==0.1.9
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-dateutil==2.9.0
|
|
||||||
|
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
"""
|
|
||||||
Access Grants Management Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Admin-only access grants history and manual grant creation.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_admin
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/access-grants", tags=["access-grants"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_access_grants(
|
|
||||||
profile_id: str = None,
|
|
||||||
active_only: bool = False,
|
|
||||||
session: dict = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Admin: List access grants.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
- profile_id: Filter by user
|
|
||||||
- active_only: Only show currently active grants
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
query = """
|
|
||||||
SELECT
|
|
||||||
ag.*,
|
|
||||||
t.name as tier_name,
|
|
||||||
p.name as profile_name,
|
|
||||||
p.email as profile_email
|
|
||||||
FROM access_grants ag
|
|
||||||
JOIN tiers t ON t.id = ag.tier_id
|
|
||||||
JOIN profiles p ON p.id = ag.profile_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
conditions = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if profile_id:
|
|
||||||
conditions.append("ag.profile_id = %s")
|
|
||||||
params.append(profile_id)
|
|
||||||
|
|
||||||
if active_only:
|
|
||||||
conditions.append("ag.is_active = true")
|
|
||||||
conditions.append("ag.valid_until > CURRENT_TIMESTAMP")
|
|
||||||
|
|
||||||
if conditions:
|
|
||||||
query += " WHERE " + " AND ".join(conditions)
|
|
||||||
|
|
||||||
query += " ORDER BY ag.valid_until DESC"
|
|
||||||
|
|
||||||
cur.execute(query, params)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_access_grant(data: dict, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Admin: Manually create access grant.
|
|
||||||
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"profile_id": "uuid",
|
|
||||||
"tier_id": "premium",
|
|
||||||
"duration_days": 30,
|
|
||||||
"reason": "Compensation for bug"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
profile_id = data.get('profile_id')
|
|
||||||
tier_id = data.get('tier_id')
|
|
||||||
duration_days = data.get('duration_days')
|
|
||||||
reason = data.get('reason', '')
|
|
||||||
|
|
||||||
if not profile_id or not tier_id or not duration_days:
|
|
||||||
raise HTTPException(400, "profile_id, tier_id und duration_days fehlen")
|
|
||||||
|
|
||||||
valid_from = datetime.now()
|
|
||||||
valid_until = valid_from + timedelta(days=duration_days)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Create grant
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO access_grants (
|
|
||||||
profile_id, tier_id, granted_by, valid_from, valid_until
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, 'admin', %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (profile_id, tier_id, valid_from, valid_until))
|
|
||||||
|
|
||||||
grant_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
# Log activity
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO user_activity_log (profile_id, action, details)
|
|
||||||
VALUES (%s, 'access_grant_created', %s)
|
|
||||||
""", (
|
|
||||||
profile_id,
|
|
||||||
f'{{"tier": "{tier_id}", "duration_days": {duration_days}, "reason": "{reason}"}}'
|
|
||||||
))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"id": grant_id,
|
|
||||||
"valid_until": valid_until.isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{grant_id}")
|
|
||||||
def update_access_grant(grant_id: str, data: dict, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Admin: Update access grant (e.g., extend duration, pause/resume).
|
|
||||||
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"is_active": false, // Pause grant
|
|
||||||
"valid_until": "2026-12-31T23:59:59" // Extend
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
if 'is_active' in data:
|
|
||||||
updates.append('is_active = %s')
|
|
||||||
values.append(data['is_active'])
|
|
||||||
|
|
||||||
if not data['is_active']:
|
|
||||||
# Pausing - calculate remaining days
|
|
||||||
cur.execute("SELECT valid_until FROM access_grants WHERE id = %s", (grant_id,))
|
|
||||||
grant = cur.fetchone()
|
|
||||||
if grant:
|
|
||||||
remaining = (grant['valid_until'] - datetime.now()).days
|
|
||||||
updates.append('remaining_days = %s')
|
|
||||||
values.append(remaining)
|
|
||||||
updates.append('paused_at = CURRENT_TIMESTAMP')
|
|
||||||
|
|
||||||
if 'valid_until' in data:
|
|
||||||
updates.append('valid_until = %s')
|
|
||||||
values.append(data['valid_until'])
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
updates.append('updated = CURRENT_TIMESTAMP')
|
|
||||||
values.append(grant_id)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE access_grants SET {', '.join(updates)} WHERE id = %s",
|
|
||||||
values
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{grant_id}")
|
|
||||||
def revoke_access_grant(grant_id: str, session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: Revoke access grant (hard delete)."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get grant info for logging
|
|
||||||
cur.execute("SELECT profile_id, tier_id FROM access_grants WHERE id = %s", (grant_id,))
|
|
||||||
grant = cur.fetchone()
|
|
||||||
|
|
||||||
if grant:
|
|
||||||
# Log revocation
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO user_activity_log (profile_id, action, details)
|
|
||||||
VALUES (%s, 'access_grant_revoked', %s)
|
|
||||||
""", (
|
|
||||||
grant['profile_id'],
|
|
||||||
f'{{"grant_id": "{grant_id}", "tier": "{grant["tier_id"]}"}}'
|
|
||||||
))
|
|
||||||
|
|
||||||
# Delete grant
|
|
||||||
cur.execute("DELETE FROM access_grants WHERE id = %s", (grant_id,))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
@ -1,460 +0,0 @@
|
||||||
"""
|
|
||||||
Activity Tracking Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles workout/activity logging, statistics, and Apple Health CSV import.
|
|
||||||
"""
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
|
||||||
from models import ActivityEntry
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
from feature_logger import log_feature_usage
|
|
||||||
from quality_filter import get_quality_filter_sql
|
|
||||||
|
|
||||||
# Evaluation import with error handling (Phase 1.2)
|
|
||||||
try:
|
|
||||||
from evaluation_helper import evaluate_and_save_activity
|
|
||||||
EVALUATION_AVAILABLE = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[AUTO-EVAL] Evaluation system not available: {e}")
|
|
||||||
EVALUATION_AVAILABLE = False
|
|
||||||
evaluate_and_save_activity = None
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get activity entries for current profile."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Issue #31: Apply global quality filter
|
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
||||||
profile = r2d(cur.fetchone())
|
|
||||||
quality_filter = get_quality_filter_sql(profile)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT * FROM activity_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
{quality_filter}
|
|
||||||
ORDER BY date DESC, start_time DESC
|
|
||||||
LIMIT %s
|
|
||||||
""", (pid, limit))
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Create new activity entry."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'activity_entries')
|
|
||||||
log_feature_usage(pid, 'activity_entries', access, 'create')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
eid = str(uuid.uuid4())
|
|
||||||
d = e.model_dump()
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""INSERT INTO activity_log
|
|
||||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
|
||||||
hr_avg,hr_max,distance_km,rpe,source,notes,created)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
||||||
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
|
|
||||||
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
|
||||||
d['rpe'],d['source'],d['notes']))
|
|
||||||
|
|
||||||
# Phase 1.2: Auto-evaluation after INSERT
|
|
||||||
if EVALUATION_AVAILABLE:
|
|
||||||
# Load the activity data to evaluate
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
|
||||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
|
||||||
rpe, pace_min_per_km, cadence, elevation_gain
|
|
||||||
FROM activity_log
|
|
||||||
WHERE id = %s
|
|
||||||
""", (eid,))
|
|
||||||
activity_row = cur.fetchone()
|
|
||||||
if activity_row:
|
|
||||||
activity_dict = dict(activity_row)
|
|
||||||
training_type_id = activity_dict.get("training_type_id")
|
|
||||||
if training_type_id:
|
|
||||||
try:
|
|
||||||
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
|
|
||||||
logger.info(f"[AUTO-EVAL] Evaluated activity {eid} on INSERT")
|
|
||||||
except Exception as eval_error:
|
|
||||||
logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}")
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter (always for new entries)
|
|
||||||
increment_feature_usage(pid, 'activity_entries')
|
|
||||||
|
|
||||||
return {"id":eid,"date":e.date}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{eid}")
|
|
||||||
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Update existing activity entry."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
d = e.model_dump()
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
|
||||||
list(d.values())+[eid,pid])
|
|
||||||
|
|
||||||
# Phase 1.2: Auto-evaluation after UPDATE
|
|
||||||
if EVALUATION_AVAILABLE:
|
|
||||||
# Load the updated activity data to evaluate
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
|
||||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
|
||||||
rpe, pace_min_per_km, cadence, elevation_gain
|
|
||||||
FROM activity_log
|
|
||||||
WHERE id = %s
|
|
||||||
""", (eid,))
|
|
||||||
activity_row = cur.fetchone()
|
|
||||||
if activity_row:
|
|
||||||
activity_dict = dict(activity_row)
|
|
||||||
training_type_id = activity_dict.get("training_type_id")
|
|
||||||
if training_type_id:
|
|
||||||
try:
|
|
||||||
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
|
|
||||||
logger.info(f"[AUTO-EVAL] Re-evaluated activity {eid} on UPDATE")
|
|
||||||
except Exception as eval_error:
|
|
||||||
logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}")
|
|
||||||
|
|
||||||
return {"id":eid}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{eid}")
|
|
||||||
def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Delete activity entry."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("DELETE FROM activity_log WHERE id=%s AND profile_id=%s", (eid,pid))
|
|
||||||
return {"ok":True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get activity statistics (last 30 entries)."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
|
|
||||||
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
|
|
||||||
total_min=sum(float(r.get('duration_min') or 0) for r in rows)
|
|
||||||
by_type={}
|
|
||||||
for r in rows:
|
|
||||||
t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
|
|
||||||
by_type[t]['count']+=1
|
|
||||||
by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
|
|
||||||
by_type[t]['min']+=float(r.get('duration_min') or 0)
|
|
||||||
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_type_for_activity(activity_type: str, profile_id: str = None):
|
|
||||||
"""
|
|
||||||
Map activity_type to training_type_id using database mappings.
|
|
||||||
|
|
||||||
Priority:
|
|
||||||
1. User-specific mapping (profile_id)
|
|
||||||
2. Global mapping (profile_id = NULL)
|
|
||||||
3. No mapping found → returns (None, None, None)
|
|
||||||
|
|
||||||
Returns: (training_type_id, category, subcategory) or (None, None, None)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Try user-specific mapping first
|
|
||||||
if profile_id:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT m.training_type_id, t.category, t.subcategory
|
|
||||||
FROM activity_type_mappings m
|
|
||||||
JOIN training_types t ON m.training_type_id = t.id
|
|
||||||
WHERE m.activity_type = %s AND m.profile_id = %s
|
|
||||||
LIMIT 1
|
|
||||||
""", (activity_type, profile_id))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
return (row['training_type_id'], row['category'], row['subcategory'])
|
|
||||||
|
|
||||||
# Try global mapping
|
|
||||||
cur.execute("""
|
|
||||||
SELECT m.training_type_id, t.category, t.subcategory
|
|
||||||
FROM activity_type_mappings m
|
|
||||||
JOIN training_types t ON m.training_type_id = t.id
|
|
||||||
WHERE m.activity_type = %s AND m.profile_id IS NULL
|
|
||||||
LIMIT 1
|
|
||||||
""", (activity_type,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
return (row['training_type_id'], row['category'], row['subcategory'])
|
|
||||||
|
|
||||||
return (None, None, None)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/uncategorized")
|
|
||||||
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get activities without assigned training type, grouped by activity_type."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT activity_type, COUNT(*) as count,
|
|
||||||
MIN(date) as first_date, MAX(date) as last_date
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND training_type_id IS NULL
|
|
||||||
GROUP BY activity_type
|
|
||||||
ORDER BY count DESC
|
|
||||||
""", (pid,))
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-categorize")
|
|
||||||
def bulk_categorize_activities(
|
|
||||||
data: dict,
|
|
||||||
x_profile_id: Optional[str]=Header(default=None),
|
|
||||||
session: dict=Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Bulk update training type for activities.
|
|
||||||
|
|
||||||
Also saves the mapping to activity_type_mappings for future imports.
|
|
||||||
|
|
||||||
Body: {
|
|
||||||
"activity_type": "Running",
|
|
||||||
"training_type_id": 1,
|
|
||||||
"training_category": "cardio",
|
|
||||||
"training_subcategory": "running"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
activity_type = data.get('activity_type')
|
|
||||||
training_type_id = data.get('training_type_id')
|
|
||||||
training_category = data.get('training_category')
|
|
||||||
training_subcategory = data.get('training_subcategory')
|
|
||||||
|
|
||||||
if not activity_type or not training_type_id:
|
|
||||||
raise HTTPException(400, "activity_type and training_type_id required")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Update existing activities
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE activity_log
|
|
||||||
SET training_type_id = %s,
|
|
||||||
training_category = %s,
|
|
||||||
training_subcategory = %s
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND activity_type = %s
|
|
||||||
AND training_type_id IS NULL
|
|
||||||
""", (training_type_id, training_category, training_subcategory, pid, activity_type))
|
|
||||||
updated_count = cur.rowcount
|
|
||||||
|
|
||||||
# Phase 1.2: Auto-evaluation after bulk categorization
|
|
||||||
if EVALUATION_AVAILABLE:
|
|
||||||
# Load all activities that were just updated and evaluate them
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
|
||||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
|
||||||
rpe, pace_min_per_km, cadence, elevation_gain
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND activity_type = %s
|
|
||||||
AND training_type_id = %s
|
|
||||||
""", (pid, activity_type, training_type_id))
|
|
||||||
|
|
||||||
activities_to_evaluate = cur.fetchall()
|
|
||||||
evaluated_count = 0
|
|
||||||
for activity_row in activities_to_evaluate:
|
|
||||||
activity_dict = dict(activity_row)
|
|
||||||
try:
|
|
||||||
evaluate_and_save_activity(cur, activity_dict["id"], activity_dict, training_type_id, pid)
|
|
||||||
evaluated_count += 1
|
|
||||||
except Exception as eval_error:
|
|
||||||
logger.warning(f"[AUTO-EVAL] Failed to evaluate bulk-categorized activity {activity_dict['id']}: {eval_error}")
|
|
||||||
|
|
||||||
logger.info(f"[AUTO-EVAL] Evaluated {evaluated_count}/{updated_count} bulk-categorized activities")
|
|
||||||
|
|
||||||
# Save mapping for future imports (upsert)
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source, updated_at)
|
|
||||||
VALUES (%s, %s, %s, 'bulk', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT (activity_type, profile_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
training_type_id = EXCLUDED.training_type_id,
|
|
||||||
source = 'bulk',
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
""", (activity_type, training_type_id, pid))
|
|
||||||
|
|
||||||
logger.info(f"[MAPPING] Saved bulk mapping: {activity_type} → training_type_id {training_type_id} (profile {pid})")
|
|
||||||
|
|
||||||
return {"updated": updated_count, "activity_type": activity_type, "mapping_saved": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-csv")
|
|
||||||
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Import Apple Health workout CSV with automatic training type mapping."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
raw = await file.read()
|
|
||||||
try: text = raw.decode('utf-8')
|
|
||||||
except: text = raw.decode('latin-1')
|
|
||||||
if text.startswith('\ufeff'): text = text[1:]
|
|
||||||
if not text.strip(): raise HTTPException(400,"Leere Datei")
|
|
||||||
reader = csv.DictReader(io.StringIO(text))
|
|
||||||
inserted = skipped = 0
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
for row in reader:
|
|
||||||
wtype = row.get('Workout Type','').strip()
|
|
||||||
start = row.get('Start','').strip()
|
|
||||||
if not wtype or not start: continue
|
|
||||||
try: date = start[:10]
|
|
||||||
except: continue
|
|
||||||
dur = row.get('Duration','').strip()
|
|
||||||
duration_min = None
|
|
||||||
if dur:
|
|
||||||
try:
|
|
||||||
p = dur.split(':')
|
|
||||||
duration_min = round(int(p[0])*60+int(p[1])+int(p[2])/60,1)
|
|
||||||
except: pass
|
|
||||||
def kj(v):
|
|
||||||
try: return round(float(v)/4.184) if v else None
|
|
||||||
except: return None
|
|
||||||
def tf(v):
|
|
||||||
try: return round(float(v),1) if v else None
|
|
||||||
except: return None
|
|
||||||
# Map activity_type to training_type_id using database mappings
|
|
||||||
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if entry already exists (duplicate detection by date + start_time)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date = %s AND start_time = %s
|
|
||||||
""", (pid, date, start))
|
|
||||||
existing = cur.fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing entry (e.g., to add training type mapping)
|
|
||||||
existing_id = existing['id']
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE activity_log
|
|
||||||
SET end_time = %s,
|
|
||||||
activity_type = %s,
|
|
||||||
duration_min = %s,
|
|
||||||
kcal_active = %s,
|
|
||||||
kcal_resting = %s,
|
|
||||||
hr_avg = %s,
|
|
||||||
hr_max = %s,
|
|
||||||
distance_km = %s,
|
|
||||||
training_type_id = %s,
|
|
||||||
training_category = %s,
|
|
||||||
training_subcategory = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""", (
|
|
||||||
row.get('End',''), wtype, duration_min,
|
|
||||||
kj(row.get('Aktive Energie (kJ)','')),
|
|
||||||
kj(row.get('Ruheeinträge (kJ)','')),
|
|
||||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
|
||||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
|
||||||
tf(row.get('Distanz (km)','')),
|
|
||||||
training_type_id, training_category, training_subcategory,
|
|
||||||
existing_id
|
|
||||||
))
|
|
||||||
skipped += 1 # Count as skipped (not newly inserted)
|
|
||||||
|
|
||||||
# Phase 1.2: Auto-evaluation after CSV import UPDATE
|
|
||||||
if EVALUATION_AVAILABLE and training_type_id:
|
|
||||||
try:
|
|
||||||
# Build activity dict for evaluation
|
|
||||||
activity_dict = {
|
|
||||||
"id": existing_id,
|
|
||||||
"profile_id": pid,
|
|
||||||
"date": date,
|
|
||||||
"training_type_id": training_type_id,
|
|
||||||
"duration_min": duration_min,
|
|
||||||
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
|
||||||
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
|
|
||||||
"distance_km": tf(row.get('Distanz (km)','')),
|
|
||||||
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
|
|
||||||
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
|
|
||||||
"rpe": None,
|
|
||||||
"pace_min_per_km": None,
|
|
||||||
"cadence": None,
|
|
||||||
"elevation_gain": None
|
|
||||||
}
|
|
||||||
evaluate_and_save_activity(cur, existing_id, activity_dict, training_type_id, pid)
|
|
||||||
logger.debug(f"[AUTO-EVAL] Re-evaluated updated activity {existing_id}")
|
|
||||||
except Exception as eval_error:
|
|
||||||
logger.warning(f"[AUTO-EVAL] Failed to re-evaluate updated activity {existing_id}: {eval_error}")
|
|
||||||
else:
|
|
||||||
# Insert new entry
|
|
||||||
new_id = str(uuid.uuid4())
|
|
||||||
cur.execute("""INSERT INTO activity_log
|
|
||||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
|
||||||
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
||||||
(new_id,pid,date,start,row.get('End',''),wtype,duration_min,
|
|
||||||
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
|
||||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
|
||||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
|
||||||
tf(row.get('Distanz (km)','')),
|
|
||||||
training_type_id,training_category,training_subcategory))
|
|
||||||
inserted+=1
|
|
||||||
|
|
||||||
# Phase 1.2: Auto-evaluation after CSV import INSERT
|
|
||||||
if EVALUATION_AVAILABLE and training_type_id:
|
|
||||||
try:
|
|
||||||
# Build activity dict for evaluation
|
|
||||||
activity_dict = {
|
|
||||||
"id": new_id,
|
|
||||||
"profile_id": pid,
|
|
||||||
"date": date,
|
|
||||||
"training_type_id": training_type_id,
|
|
||||||
"duration_min": duration_min,
|
|
||||||
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
|
||||||
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
|
|
||||||
"distance_km": tf(row.get('Distanz (km)','')),
|
|
||||||
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
|
|
||||||
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
|
|
||||||
"rpe": None,
|
|
||||||
"pace_min_per_km": None,
|
|
||||||
"cadence": None,
|
|
||||||
"elevation_gain": None
|
|
||||||
}
|
|
||||||
evaluate_and_save_activity(cur, new_id, activity_dict, training_type_id, pid)
|
|
||||||
logger.debug(f"[AUTO-EVAL] Evaluated imported activity {new_id}")
|
|
||||||
except Exception as eval_error:
|
|
||||||
logger.warning(f"[AUTO-EVAL] Failed to evaluate imported activity {new_id}: {eval_error}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Import row failed: {e}")
|
|
||||||
skipped+=1
|
|
||||||
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
"""
|
|
||||||
Admin Management Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles user management, permissions, and email testing (admin-only).
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import smtplib
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_admin, hash_pin
|
|
||||||
from models import AdminProfileUpdate
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles")
|
|
||||||
def admin_list_profiles(session: dict=Depends(require_admin)):
|
|
||||||
"""Admin: List all profiles with stats."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM profiles ORDER BY created")
|
|
||||||
profs = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
for p in profs:
|
|
||||||
pid = p['id']
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s", (pid,))
|
|
||||||
p['weight_count'] = cur.fetchone()['count']
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM ai_insights WHERE profile_id=%s", (pid,))
|
|
||||||
p['ai_insights_count'] = cur.fetchone()['count']
|
|
||||||
|
|
||||||
today = datetime.now().date().isoformat()
|
|
||||||
cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
|
|
||||||
usage = cur.fetchone()
|
|
||||||
p['ai_usage_today'] = usage['call_count'] if usage else 0
|
|
||||||
|
|
||||||
return profs
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profiles/{pid}")
|
|
||||||
def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depends(require_admin)):
|
|
||||||
"""Admin: Update profile settings."""
|
|
||||||
with get_db() as conn:
|
|
||||||
updates = {k:v for k,v in data.model_dump().items() if v is not None}
|
|
||||||
if not updates:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in updates)} WHERE id=%s",
|
|
||||||
list(updates.values()) + [pid])
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profiles/{pid}/permissions")
|
|
||||||
def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)):
|
|
||||||
"""Admin: Set profile permissions."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
if 'ai_enabled' in data:
|
|
||||||
updates.append('ai_enabled=%s')
|
|
||||||
values.append(data['ai_enabled'])
|
|
||||||
if 'ai_limit_day' in data:
|
|
||||||
updates.append('ai_limit_day=%s')
|
|
||||||
values.append(data['ai_limit_day'])
|
|
||||||
if 'export_enabled' in data:
|
|
||||||
updates.append('export_enabled=%s')
|
|
||||||
values.append(data['export_enabled'])
|
|
||||||
if 'role' in data:
|
|
||||||
updates.append('role=%s')
|
|
||||||
values.append(data['role'])
|
|
||||||
|
|
||||||
if updates:
|
|
||||||
cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid])
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profiles/{pid}/email")
|
|
||||||
def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)):
|
|
||||||
"""Admin: Set profile email."""
|
|
||||||
email = data.get('email', '').strip().lower()
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid))
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profiles/{pid}/pin")
|
|
||||||
def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)):
|
|
||||||
"""Admin: Set profile PIN/password."""
|
|
||||||
new_pin = data.get('pin', '')
|
|
||||||
if len(new_pin) < 4:
|
|
||||||
raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
|
|
||||||
|
|
||||||
new_hash = hash_pin(new_pin)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/email/status")
|
|
||||||
def admin_email_status(session: dict=Depends(require_admin)):
|
|
||||||
"""Admin: Check email configuration status."""
|
|
||||||
smtp_host = os.getenv("SMTP_HOST")
|
|
||||||
smtp_user = os.getenv("SMTP_USER")
|
|
||||||
smtp_pass = os.getenv("SMTP_PASS")
|
|
||||||
app_url = os.getenv("APP_URL", "http://localhost:3002")
|
|
||||||
|
|
||||||
configured = bool(smtp_host and smtp_user and smtp_pass)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"configured": configured,
|
|
||||||
"smtp_host": smtp_host or "",
|
|
||||||
"smtp_user": smtp_user or "",
|
|
||||||
"app_url": app_url
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/email/test")
|
|
||||||
def admin_test_email(data: dict, session: dict=Depends(require_admin)):
|
|
||||||
"""Admin: Send test email."""
|
|
||||||
email = data.get('to', '')
|
|
||||||
if not email:
|
|
||||||
raise HTTPException(400, "E-Mail-Adresse fehlt")
|
|
||||||
|
|
||||||
try:
|
|
||||||
smtp_host = os.getenv("SMTP_HOST")
|
|
||||||
smtp_port = int(os.getenv("SMTP_PORT", 587))
|
|
||||||
smtp_user = os.getenv("SMTP_USER")
|
|
||||||
smtp_pass = os.getenv("SMTP_PASS")
|
|
||||||
smtp_from = os.getenv("SMTP_FROM")
|
|
||||||
|
|
||||||
if not smtp_host or not smtp_user or not smtp_pass:
|
|
||||||
raise HTTPException(500, "SMTP nicht konfiguriert")
|
|
||||||
|
|
||||||
msg = MIMEText("Dies ist eine Test-E-Mail von Mitai Jinkendo.")
|
|
||||||
msg['Subject'] = "Test-E-Mail"
|
|
||||||
msg['From'] = smtp_from
|
|
||||||
msg['To'] = email
|
|
||||||
|
|
||||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
|
||||||
server.starttls()
|
|
||||||
server.login(smtp_user, smtp_pass)
|
|
||||||
server.send_message(msg)
|
|
||||||
|
|
||||||
return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Fehler beim Senden: {str(e)}")
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
"""
|
|
||||||
Admin Activity Type Mappings Management - v9d Phase 1b
|
|
||||||
|
|
||||||
CRUD operations for activity_type_mappings (learnable system).
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_admin
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/activity-mappings", tags=["admin", "activity-mappings"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityMappingCreate(BaseModel):
|
|
||||||
activity_type: str
|
|
||||||
training_type_id: int
|
|
||||||
profile_id: Optional[str] = None
|
|
||||||
source: str = 'admin'
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityMappingUpdate(BaseModel):
|
|
||||||
training_type_id: Optional[int] = None
|
|
||||||
profile_id: Optional[str] = None
|
|
||||||
source: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_activity_mappings(
|
|
||||||
profile_id: Optional[str] = None,
|
|
||||||
global_only: bool = False,
|
|
||||||
session: dict = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get all activity type mappings.
|
|
||||||
|
|
||||||
Filters:
|
|
||||||
- profile_id: Show only mappings for specific profile
|
|
||||||
- global_only: Show only global mappings (profile_id IS NULL)
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
query = """
|
|
||||||
SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source,
|
|
||||||
m.created_at, m.updated_at,
|
|
||||||
t.name_de as training_type_name_de,
|
|
||||||
t.category, t.subcategory, t.icon
|
|
||||||
FROM activity_type_mappings m
|
|
||||||
JOIN training_types t ON m.training_type_id = t.id
|
|
||||||
"""
|
|
||||||
|
|
||||||
conditions = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if global_only:
|
|
||||||
conditions.append("m.profile_id IS NULL")
|
|
||||||
elif profile_id:
|
|
||||||
conditions.append("m.profile_id = %s")
|
|
||||||
params.append(profile_id)
|
|
||||||
|
|
||||||
if conditions:
|
|
||||||
query += " WHERE " + " AND ".join(conditions)
|
|
||||||
|
|
||||||
query += " ORDER BY m.activity_type"
|
|
||||||
|
|
||||||
cur.execute(query, params)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
return [r2d(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{mapping_id}")
|
|
||||||
def get_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)):
|
|
||||||
"""Get single activity mapping by ID."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source,
|
|
||||||
m.created_at, m.updated_at,
|
|
||||||
t.name_de as training_type_name_de,
|
|
||||||
t.category, t.subcategory
|
|
||||||
FROM activity_type_mappings m
|
|
||||||
JOIN training_types t ON m.training_type_id = t.id
|
|
||||||
WHERE m.id = %s
|
|
||||||
""", (mapping_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Mapping not found")
|
|
||||||
|
|
||||||
return r2d(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_activity_mapping(data: ActivityMappingCreate, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Create new activity type mapping.
|
|
||||||
|
|
||||||
Note: Duplicate (activity_type, profile_id) will fail with 409 Conflict.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO activity_type_mappings
|
|
||||||
(activity_type, training_type_id, profile_id, source)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
data.activity_type,
|
|
||||||
data.training_type_id,
|
|
||||||
data.profile_id,
|
|
||||||
data.source
|
|
||||||
))
|
|
||||||
|
|
||||||
new_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
logger.info(f"[ADMIN] Mapping created: {data.activity_type} → training_type_id {data.training_type_id} (profile: {data.profile_id})")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if 'unique_activity_type_per_profile' in str(e):
|
|
||||||
raise HTTPException(409, f"Mapping for '{data.activity_type}' already exists (profile: {data.profile_id})")
|
|
||||||
raise HTTPException(400, f"Failed to create mapping: {str(e)}")
|
|
||||||
|
|
||||||
return {"id": new_id, "message": "Mapping created"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{mapping_id}")
|
|
||||||
def update_activity_mapping(
|
|
||||||
mapping_id: int,
|
|
||||||
data: ActivityMappingUpdate,
|
|
||||||
session: dict = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Update existing activity type mapping."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Build update query dynamically
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
if data.training_type_id is not None:
|
|
||||||
updates.append("training_type_id = %s")
|
|
||||||
values.append(data.training_type_id)
|
|
||||||
if data.profile_id is not None:
|
|
||||||
updates.append("profile_id = %s")
|
|
||||||
values.append(data.profile_id)
|
|
||||||
if data.source is not None:
|
|
||||||
updates.append("source = %s")
|
|
||||||
values.append(data.source)
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
raise HTTPException(400, "No fields to update")
|
|
||||||
|
|
||||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
||||||
values.append(mapping_id)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
UPDATE activity_type_mappings
|
|
||||||
SET {', '.join(updates)}
|
|
||||||
WHERE id = %s
|
|
||||||
""", values)
|
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
raise HTTPException(404, "Mapping not found")
|
|
||||||
|
|
||||||
logger.info(f"[ADMIN] Mapping updated: {mapping_id}")
|
|
||||||
|
|
||||||
return {"id": mapping_id, "message": "Mapping updated"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{mapping_id}")
|
|
||||||
def delete_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Delete activity type mapping.
|
|
||||||
|
|
||||||
This will cause future imports to NOT auto-assign training type for this activity_type.
|
|
||||||
Existing activities with this mapping remain unchanged.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("DELETE FROM activity_type_mappings WHERE id = %s", (mapping_id,))
|
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
raise HTTPException(404, "Mapping not found")
|
|
||||||
|
|
||||||
logger.info(f"[ADMIN] Mapping deleted: {mapping_id}")
|
|
||||||
|
|
||||||
return {"message": "Mapping deleted"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats/coverage")
|
|
||||||
def get_mapping_coverage(session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Get statistics about mapping coverage.
|
|
||||||
|
|
||||||
Returns how many activities are mapped vs unmapped across all profiles.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total_activities,
|
|
||||||
COUNT(training_type_id) as mapped_activities,
|
|
||||||
COUNT(*) - COUNT(training_type_id) as unmapped_activities,
|
|
||||||
COUNT(DISTINCT activity_type) as unique_activity_types,
|
|
||||||
COUNT(DISTINCT CASE WHEN training_type_id IS NULL THEN activity_type END) as unmapped_types
|
|
||||||
FROM activity_log
|
|
||||||
""")
|
|
||||||
stats = r2d(cur.fetchone())
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
"""
|
|
||||||
Admin Training Types Management - v9d Phase 1b
|
|
||||||
|
|
||||||
CRUD operations for training types with abilities mapping preparation.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from psycopg2.extras import Json
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, require_admin
|
|
||||||
from profile_templates import list_templates, get_template
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingTypeCreate(BaseModel):
|
|
||||||
category: str
|
|
||||||
subcategory: Optional[str] = None
|
|
||||||
name_de: str
|
|
||||||
name_en: str
|
|
||||||
icon: Optional[str] = None
|
|
||||||
description_de: Optional[str] = None
|
|
||||||
description_en: Optional[str] = None
|
|
||||||
sort_order: int = 0
|
|
||||||
abilities: Optional[dict] = None
|
|
||||||
profile: Optional[dict] = None # Training Type Profile (Phase 2 #15)
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingTypeUpdate(BaseModel):
|
|
||||||
category: Optional[str] = None
|
|
||||||
subcategory: Optional[str] = None
|
|
||||||
name_de: Optional[str] = None
|
|
||||||
name_en: Optional[str] = None
|
|
||||||
icon: Optional[str] = None
|
|
||||||
description_de: Optional[str] = None
|
|
||||||
description_en: Optional[str] = None
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
abilities: Optional[dict] = None
|
|
||||||
profile: Optional[dict] = None # Training Type Profile (Phase 2 #15)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_training_types_admin(session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Get all training types for admin management.
|
|
||||||
Returns full details including abilities.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, category, subcategory, name_de, name_en, icon,
|
|
||||||
description_de, description_en, sort_order, abilities,
|
|
||||||
profile, created_at
|
|
||||||
FROM training_types
|
|
||||||
ORDER BY sort_order, category, subcategory
|
|
||||||
""")
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
return [r2d(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{type_id}")
|
|
||||||
def get_training_type(type_id: int, session: dict = Depends(require_admin)):
|
|
||||||
"""Get single training type by ID."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, category, subcategory, name_de, name_en, icon,
|
|
||||||
description_de, description_en, sort_order, abilities,
|
|
||||||
profile, created_at
|
|
||||||
FROM training_types
|
|
||||||
WHERE id = %s
|
|
||||||
""", (type_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Training type not found")
|
|
||||||
|
|
||||||
return r2d(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_training_type(data: TrainingTypeCreate, session: dict = Depends(require_admin)):
|
|
||||||
"""Create new training type."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Convert abilities and profile dict to JSONB
|
|
||||||
abilities_json = data.abilities if data.abilities else {}
|
|
||||||
profile_json = data.profile if data.profile else None
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO training_types
|
|
||||||
(category, subcategory, name_de, name_en, icon,
|
|
||||||
description_de, description_en, sort_order, abilities, profile)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
data.category,
|
|
||||||
data.subcategory,
|
|
||||||
data.name_de,
|
|
||||||
data.name_en,
|
|
||||||
data.icon,
|
|
||||||
data.description_de,
|
|
||||||
data.description_en,
|
|
||||||
data.sort_order,
|
|
||||||
Json(abilities_json),
|
|
||||||
Json(profile_json) if profile_json else None
|
|
||||||
))
|
|
||||||
|
|
||||||
new_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
logger.info(f"[ADMIN] Training type created: {new_id} - {data.name_de} ({data.category}/{data.subcategory})")
|
|
||||||
|
|
||||||
return {"id": new_id, "message": "Training type created"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{type_id}")
|
|
||||||
def update_training_type(
|
|
||||||
type_id: int,
|
|
||||||
data: TrainingTypeUpdate,
|
|
||||||
session: dict = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""Update existing training type."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Build update query dynamically
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
if data.category is not None:
|
|
||||||
updates.append("category = %s")
|
|
||||||
values.append(data.category)
|
|
||||||
if data.subcategory is not None:
|
|
||||||
updates.append("subcategory = %s")
|
|
||||||
values.append(data.subcategory)
|
|
||||||
if data.name_de is not None:
|
|
||||||
updates.append("name_de = %s")
|
|
||||||
values.append(data.name_de)
|
|
||||||
if data.name_en is not None:
|
|
||||||
updates.append("name_en = %s")
|
|
||||||
values.append(data.name_en)
|
|
||||||
if data.icon is not None:
|
|
||||||
updates.append("icon = %s")
|
|
||||||
values.append(data.icon)
|
|
||||||
if data.description_de is not None:
|
|
||||||
updates.append("description_de = %s")
|
|
||||||
values.append(data.description_de)
|
|
||||||
if data.description_en is not None:
|
|
||||||
updates.append("description_en = %s")
|
|
||||||
values.append(data.description_en)
|
|
||||||
if data.sort_order is not None:
|
|
||||||
updates.append("sort_order = %s")
|
|
||||||
values.append(data.sort_order)
|
|
||||||
if data.abilities is not None:
|
|
||||||
updates.append("abilities = %s")
|
|
||||||
values.append(Json(data.abilities))
|
|
||||||
if data.profile is not None:
|
|
||||||
updates.append("profile = %s")
|
|
||||||
values.append(Json(data.profile))
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
raise HTTPException(400, "No fields to update")
|
|
||||||
|
|
||||||
values.append(type_id)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
UPDATE training_types
|
|
||||||
SET {', '.join(updates)}
|
|
||||||
WHERE id = %s
|
|
||||||
""", values)
|
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
raise HTTPException(404, "Training type not found")
|
|
||||||
|
|
||||||
logger.info(f"[ADMIN] Training type updated: {type_id}")
|
|
||||||
|
|
||||||
return {"id": type_id, "message": "Training type updated"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{type_id}")
|
|
||||||
def delete_training_type(type_id: int, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Delete training type.
|
|
||||||
|
|
||||||
WARNING: This will fail if any activities reference this type.
|
|
||||||
Consider adding a soft-delete or archive mechanism if needed.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check if any activities use this type
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE training_type_id = %s
|
|
||||||
""", (type_id,))
|
|
||||||
|
|
||||||
count = cur.fetchone()['count']
|
|
||||||
if count > 0:
|
|
||||||
raise HTTPException(
|
|
||||||
400,
|
|
||||||
f"Cannot delete: {count} activities are using this training type. "
|
|
||||||
"Please reassign or delete those activities first."
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,))
|
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
raise HTTPException(404, "Training type not found")
|
|
||||||
|
|
||||||
logger.info(f"[ADMIN] Training type deleted: {type_id}")
|
|
||||||
|
|
||||||
return {"message": "Training type deleted"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/taxonomy/abilities")
|
|
||||||
def get_abilities_taxonomy(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Get abilities taxonomy for UI and AI analysis.
|
|
||||||
|
|
||||||
This defines the 5 dimensions of athletic development.
|
|
||||||
"""
|
|
||||||
taxonomy = {
|
|
||||||
"koordinativ": {
|
|
||||||
"name_de": "Koordinative Fähigkeiten",
|
|
||||||
"name_en": "Coordination Abilities",
|
|
||||||
"icon": "🎯",
|
|
||||||
"abilities": [
|
|
||||||
{"key": "orientierung", "name_de": "Orientierung", "name_en": "Orientation"},
|
|
||||||
{"key": "differenzierung", "name_de": "Differenzierung", "name_en": "Differentiation"},
|
|
||||||
{"key": "kopplung", "name_de": "Kopplung", "name_en": "Coupling"},
|
|
||||||
{"key": "gleichgewicht", "name_de": "Gleichgewicht", "name_en": "Balance"},
|
|
||||||
{"key": "rhythmus", "name_de": "Rhythmisierung", "name_en": "Rhythm"},
|
|
||||||
{"key": "reaktion", "name_de": "Reaktion", "name_en": "Reaction"},
|
|
||||||
{"key": "umstellung", "name_de": "Umstellung", "name_en": "Adaptation"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"konditionell": {
|
|
||||||
"name_de": "Konditionelle Fähigkeiten",
|
|
||||||
"name_en": "Conditional Abilities",
|
|
||||||
"icon": "💪",
|
|
||||||
"abilities": [
|
|
||||||
{"key": "kraft", "name_de": "Kraft", "name_en": "Strength"},
|
|
||||||
{"key": "ausdauer", "name_de": "Ausdauer", "name_en": "Endurance"},
|
|
||||||
{"key": "schnelligkeit", "name_de": "Schnelligkeit", "name_en": "Speed"},
|
|
||||||
{"key": "flexibilitaet", "name_de": "Flexibilität", "name_en": "Flexibility"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"kognitiv": {
|
|
||||||
"name_de": "Kognitive Fähigkeiten",
|
|
||||||
"name_en": "Cognitive Abilities",
|
|
||||||
"icon": "🧠",
|
|
||||||
"abilities": [
|
|
||||||
{"key": "konzentration", "name_de": "Konzentration", "name_en": "Concentration"},
|
|
||||||
{"key": "aufmerksamkeit", "name_de": "Aufmerksamkeit", "name_en": "Attention"},
|
|
||||||
{"key": "wahrnehmung", "name_de": "Wahrnehmung", "name_en": "Perception"},
|
|
||||||
{"key": "entscheidung", "name_de": "Entscheidungsfindung", "name_en": "Decision Making"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"psychisch": {
|
|
||||||
"name_de": "Psychische Fähigkeiten",
|
|
||||||
"name_en": "Psychological Abilities",
|
|
||||||
"icon": "🎭",
|
|
||||||
"abilities": [
|
|
||||||
{"key": "motivation", "name_de": "Motivation", "name_en": "Motivation"},
|
|
||||||
{"key": "willenskraft", "name_de": "Willenskraft", "name_en": "Willpower"},
|
|
||||||
{"key": "stressresistenz", "name_de": "Stressresistenz", "name_en": "Stress Resistance"},
|
|
||||||
{"key": "selbstvertrauen", "name_de": "Selbstvertrauen", "name_en": "Self-Confidence"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"taktisch": {
|
|
||||||
"name_de": "Taktische Fähigkeiten",
|
|
||||||
"name_en": "Tactical Abilities",
|
|
||||||
"icon": "♟️",
|
|
||||||
"abilities": [
|
|
||||||
{"key": "timing", "name_de": "Timing", "name_en": "Timing"},
|
|
||||||
{"key": "strategie", "name_de": "Strategie", "name_en": "Strategy"},
|
|
||||||
{"key": "antizipation", "name_de": "Antizipation", "name_en": "Anticipation"},
|
|
||||||
{"key": "situationsanalyse", "name_de": "Situationsanalyse", "name_en": "Situation Analysis"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return taxonomy
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# TRAINING TYPE PROFILES - Phase 2 (#15)
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
@router.get("/profiles/templates")
|
|
||||||
def list_profile_templates(session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
List all available profile templates.
|
|
||||||
|
|
||||||
Returns templates for common training types (Running, Meditation, Strength, etc.)
|
|
||||||
"""
|
|
||||||
return list_templates()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles/templates/{template_key}")
|
|
||||||
def get_profile_template(template_key: str, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Get a specific profile template by key.
|
|
||||||
|
|
||||||
Keys: running, meditation, strength
|
|
||||||
"""
|
|
||||||
template = get_template(template_key)
|
|
||||||
if not template:
|
|
||||||
raise HTTPException(404, f"Template '{template_key}' not found")
|
|
||||||
return template
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{type_id}/profile/apply-template")
|
|
||||||
def apply_profile_template(
|
|
||||||
type_id: int,
|
|
||||||
data: dict,
|
|
||||||
session: dict = Depends(require_admin)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Apply a profile template to a training type.
|
|
||||||
|
|
||||||
Body: { "template_key": "running" }
|
|
||||||
"""
|
|
||||||
template_key = data.get("template_key")
|
|
||||||
if not template_key:
|
|
||||||
raise HTTPException(400, "template_key required")
|
|
||||||
|
|
||||||
template = get_template(template_key)
|
|
||||||
if not template:
|
|
||||||
raise HTTPException(404, f"Template '{template_key}' not found")
|
|
||||||
|
|
||||||
# Apply template to training type
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check if training type exists
|
|
||||||
cur.execute("SELECT id, name_de FROM training_types WHERE id = %s", (type_id,))
|
|
||||||
training_type = cur.fetchone()
|
|
||||||
if not training_type:
|
|
||||||
raise HTTPException(404, "Training type not found")
|
|
||||||
|
|
||||||
# Update profile
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE training_types
|
|
||||||
SET profile = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""", (Json(template), type_id))
|
|
||||||
|
|
||||||
logger.info(f"[ADMIN] Applied template '{template_key}' to training type {type_id} ({training_type['name_de']})")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": f"Template '{template_key}' applied successfully",
|
|
||||||
"training_type_id": type_id,
|
|
||||||
"training_type_name": training_type['name_de'],
|
|
||||||
"template_key": template_key
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles/stats")
|
|
||||||
def get_profile_stats(session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Get statistics about configured profiles.
|
|
||||||
|
|
||||||
Returns count of training types with/without profiles.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(profile) as configured,
|
|
||||||
COUNT(*) - COUNT(profile) as unconfigured
|
|
||||||
FROM training_types
|
|
||||||
""")
|
|
||||||
stats = cur.fetchone()
|
|
||||||
|
|
||||||
# Get list of types with profiles
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, name_de, category, subcategory
|
|
||||||
FROM training_types
|
|
||||||
WHERE profile IS NOT NULL
|
|
||||||
ORDER BY name_de
|
|
||||||
""")
|
|
||||||
configured_types = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Get list of types without profiles
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, name_de, category, subcategory
|
|
||||||
FROM training_types
|
|
||||||
WHERE profile IS NULL
|
|
||||||
ORDER BY name_de
|
|
||||||
""")
|
|
||||||
unconfigured_types = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": stats['total'],
|
|
||||||
"configured": stats['configured'],
|
|
||||||
"unconfigured": stats['unconfigured'],
|
|
||||||
"configured_types": configured_types,
|
|
||||||
"unconfigured_types": unconfigured_types
|
|
||||||
}
|
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
"""
|
|
||||||
Authentication Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles login, logout, password reset, and profile authentication.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import smtplib
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
|
||||||
from starlette.requests import Request
|
|
||||||
from slowapi import Limiter
|
|
||||||
from slowapi.util import get_remote_address
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from auth import hash_pin, verify_pin, make_token, require_auth
|
|
||||||
from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
@limiter.limit("5/minute")
|
|
||||||
async def login(req: LoginRequest, request: Request):
|
|
||||||
"""Login with email + password."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
|
|
||||||
prof = cur.fetchone()
|
|
||||||
if not prof:
|
|
||||||
raise HTTPException(401, "Ungültige Zugangsdaten")
|
|
||||||
|
|
||||||
# Verify password
|
|
||||||
if not verify_pin(req.password, prof['pin_hash']):
|
|
||||||
raise HTTPException(401, "Ungültige Zugangsdaten")
|
|
||||||
|
|
||||||
# Auto-upgrade from SHA256 to bcrypt
|
|
||||||
if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
|
|
||||||
new_hash = hash_pin(req.password)
|
|
||||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
|
|
||||||
|
|
||||||
# Create session
|
|
||||||
token = make_token()
|
|
||||||
session_days = prof.get('session_days', 30)
|
|
||||||
expires = datetime.now() + timedelta(days=session_days)
|
|
||||||
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
|
|
||||||
(token, prof['id'], expires.isoformat()))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"token": token,
|
|
||||||
"profile_id": prof['id'],
|
|
||||||
"name": prof['name'],
|
|
||||||
"role": prof['role'],
|
|
||||||
"expires_at": expires.isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
def logout(x_auth_token: Optional[str]=Header(default=None)):
|
|
||||||
"""Logout (delete session)."""
|
|
||||||
if x_auth_token:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,))
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
|
||||||
def get_me(session: dict=Depends(require_auth)):
|
|
||||||
"""Get current user info."""
|
|
||||||
pid = session['profile_id']
|
|
||||||
# Import here to avoid circular dependency
|
|
||||||
from routers.profiles import get_profile
|
|
||||||
return get_profile(pid, session)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
def auth_status():
|
|
||||||
"""Health check endpoint."""
|
|
||||||
return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/pin")
|
|
||||||
def change_pin(req: dict, session: dict=Depends(require_auth)):
|
|
||||||
"""Change PIN/password for current user."""
|
|
||||||
pid = session['profile_id']
|
|
||||||
new_pin = req.get('pin', '')
|
|
||||||
if len(new_pin) < 4:
|
|
||||||
raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
|
|
||||||
|
|
||||||
new_hash = hash_pin(new_pin)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/forgot-password")
|
|
||||||
@limiter.limit("3/minute")
|
|
||||||
async def password_reset_request(req: PasswordResetRequest, request: Request):
|
|
||||||
"""Request password reset email."""
|
|
||||||
email = req.email.lower().strip()
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
|
|
||||||
prof = cur.fetchone()
|
|
||||||
if not prof:
|
|
||||||
# Don't reveal if email exists
|
|
||||||
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
|
|
||||||
|
|
||||||
# Generate reset token
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
expires = datetime.now() + timedelta(hours=1)
|
|
||||||
|
|
||||||
# Store in sessions table (reuse mechanism)
|
|
||||||
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
|
|
||||||
(f"reset_{token}", prof['id'], expires.isoformat()))
|
|
||||||
|
|
||||||
# Send email
|
|
||||||
try:
|
|
||||||
smtp_host = os.getenv("SMTP_HOST")
|
|
||||||
smtp_port = int(os.getenv("SMTP_PORT", 587))
|
|
||||||
smtp_user = os.getenv("SMTP_USER")
|
|
||||||
smtp_pass = os.getenv("SMTP_PASS")
|
|
||||||
smtp_from = os.getenv("SMTP_FROM")
|
|
||||||
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
|
||||||
|
|
||||||
if smtp_host and smtp_user and smtp_pass:
|
|
||||||
msg = MIMEText(f"""Hallo {prof['name']},
|
|
||||||
|
|
||||||
Du hast einen Passwort-Reset angefordert.
|
|
||||||
|
|
||||||
Reset-Link: {app_url}/reset-password?token={token}
|
|
||||||
|
|
||||||
Der Link ist 1 Stunde gültig.
|
|
||||||
|
|
||||||
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
|
|
||||||
|
|
||||||
Dein Mitai Jinkendo Team
|
|
||||||
""")
|
|
||||||
msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo"
|
|
||||||
msg['From'] = smtp_from
|
|
||||||
msg['To'] = email
|
|
||||||
|
|
||||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
|
||||||
server.starttls()
|
|
||||||
server.login(smtp_user, smtp_pass)
|
|
||||||
server.send_message(msg)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Email error: {e}")
|
|
||||||
|
|
||||||
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset-password")
|
|
||||||
def password_reset_confirm(req: PasswordResetConfirm):
|
|
||||||
"""Confirm password reset with token."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP",
|
|
||||||
(f"reset_{req.token}",))
|
|
||||||
sess = cur.fetchone()
|
|
||||||
if not sess:
|
|
||||||
raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
|
|
||||||
|
|
||||||
pid = sess['profile_id']
|
|
||||||
new_hash = hash_pin(req.new_password)
|
|
||||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
|
||||||
cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
|
|
||||||
|
|
||||||
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helper: Send Email ────────────────────────────────────────────────────────
|
|
||||||
def send_email(to_email: str, subject: str, body: str):
|
|
||||||
"""Send email via SMTP (reusable helper)."""
|
|
||||||
try:
|
|
||||||
smtp_host = os.getenv("SMTP_HOST")
|
|
||||||
smtp_port = int(os.getenv("SMTP_PORT", 587))
|
|
||||||
smtp_user = os.getenv("SMTP_USER")
|
|
||||||
smtp_pass = os.getenv("SMTP_PASS")
|
|
||||||
smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
|
|
||||||
|
|
||||||
if not smtp_host or not smtp_user or not smtp_pass:
|
|
||||||
print("SMTP not configured, skipping email")
|
|
||||||
return False
|
|
||||||
|
|
||||||
msg = MIMEText(body)
|
|
||||||
msg['Subject'] = subject
|
|
||||||
msg['From'] = smtp_from
|
|
||||||
msg['To'] = to_email
|
|
||||||
|
|
||||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
|
||||||
server.starttls()
|
|
||||||
server.login(smtp_user, smtp_pass)
|
|
||||||
server.send_message(msg)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Email error: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# ── Registration Endpoints ────────────────────────────────────────────────────
|
|
||||||
@router.post("/register")
|
|
||||||
@limiter.limit("3/hour")
|
|
||||||
async def register(req: RegisterRequest, request: Request):
|
|
||||||
"""Self-registration with email verification."""
|
|
||||||
email = req.email.lower().strip()
|
|
||||||
name = req.name.strip()
|
|
||||||
password = req.password
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
if not email or '@' not in email:
|
|
||||||
raise HTTPException(400, "Ungültige E-Mail-Adresse")
|
|
||||||
if len(password) < 8:
|
|
||||||
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein")
|
|
||||||
if not name or len(name) < 2:
|
|
||||||
raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check if email already exists
|
|
||||||
cur.execute("SELECT id FROM profiles WHERE email=%s", (email,))
|
|
||||||
if cur.fetchone():
|
|
||||||
raise HTTPException(400, "E-Mail-Adresse bereits registriert")
|
|
||||||
|
|
||||||
# Generate verification token
|
|
||||||
verification_token = secrets.token_urlsafe(32)
|
|
||||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
|
||||||
|
|
||||||
# Create profile (inactive until verified)
|
|
||||||
profile_id = str(secrets.token_hex(16))
|
|
||||||
pin_hash = hash_pin(password)
|
|
||||||
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO profiles (
|
|
||||||
id, name, email, pin_hash, auth_type, role, tier,
|
|
||||||
email_verified, verification_token, verification_expires,
|
|
||||||
trial_ends_at, created
|
|
||||||
) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
|
||||||
""", (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends))
|
|
||||||
|
|
||||||
# Send verification email
|
|
||||||
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
|
||||||
verify_url = f"{app_url}/verify?token={verification_token}"
|
|
||||||
|
|
||||||
email_body = f"""Hallo {name},
|
|
||||||
|
|
||||||
willkommen bei Mitai Jinkendo!
|
|
||||||
|
|
||||||
Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen:
|
|
||||||
|
|
||||||
{verify_url}
|
|
||||||
|
|
||||||
Der Link ist 24 Stunden gültig.
|
|
||||||
|
|
||||||
Dein Mitai Jinkendo Team
|
|
||||||
"""
|
|
||||||
|
|
||||||
send_email(email, "Willkommen bei Mitai Jinkendo – E-Mail bestätigen", email_body)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/verify/{token}")
|
|
||||||
async def verify_email(token: str):
|
|
||||||
"""Verify email address and activate account."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Find profile with this verification token
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, name, email, email_verified, verification_expires
|
|
||||||
FROM profiles
|
|
||||||
WHERE verification_token=%s
|
|
||||||
""", (token,))
|
|
||||||
|
|
||||||
prof = cur.fetchone()
|
|
||||||
|
|
||||||
if not prof:
|
|
||||||
# Token not found - might be already used/verified
|
|
||||||
# Check if there's a verified profile (token was deleted after verification)
|
|
||||||
raise HTTPException(400, "Verifikations-Link ungültig oder bereits verwendet. Falls du bereits verifiziert bist, melde dich einfach an.")
|
|
||||||
|
|
||||||
if prof['email_verified']:
|
|
||||||
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
|
|
||||||
|
|
||||||
# Check if token expired
|
|
||||||
if prof['verification_expires'] and datetime.now(timezone.utc) > prof['verification_expires']:
|
|
||||||
raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.")
|
|
||||||
|
|
||||||
# Mark as verified and clear token
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE profiles
|
|
||||||
SET email_verified=TRUE, verification_token=NULL, verification_expires=NULL
|
|
||||||
WHERE id=%s
|
|
||||||
""", (prof['id'],))
|
|
||||||
|
|
||||||
# Create session (auto-login after verification)
|
|
||||||
session_token = make_token()
|
|
||||||
expires = datetime.now(timezone.utc) + timedelta(days=30)
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO sessions (token, profile_id, expires_at, created)
|
|
||||||
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
|
|
||||||
""", (session_token, prof['id'], expires))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"message": "E-Mail-Adresse erfolgreich bestätigt!",
|
|
||||||
"token": session_token,
|
|
||||||
"profile": {
|
|
||||||
"id": prof['id'],
|
|
||||||
"name": prof['name'],
|
|
||||||
"email": prof['email']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resend-verification")
|
|
||||||
@limiter.limit("3/hour")
|
|
||||||
async def resend_verification(req: dict, request: Request):
|
|
||||||
"""Resend verification email for unverified account."""
|
|
||||||
email = req.get('email', '').strip().lower()
|
|
||||||
|
|
||||||
if not email:
|
|
||||||
raise HTTPException(400, "E-Mail-Adresse erforderlich")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Find profile by email
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, name, email, email_verified, verification_token, verification_expires
|
|
||||||
FROM profiles
|
|
||||||
WHERE email=%s
|
|
||||||
""", (email,))
|
|
||||||
|
|
||||||
prof = cur.fetchone()
|
|
||||||
|
|
||||||
if not prof:
|
|
||||||
# Don't leak info about existing emails
|
|
||||||
return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."}
|
|
||||||
|
|
||||||
if prof['email_verified']:
|
|
||||||
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
|
|
||||||
|
|
||||||
# Generate new verification token
|
|
||||||
verification_token = secrets.token_urlsafe(32)
|
|
||||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE profiles
|
|
||||||
SET verification_token=%s, verification_expires=%s
|
|
||||||
WHERE id=%s
|
|
||||||
""", (verification_token, verification_expires, prof['id']))
|
|
||||||
|
|
||||||
# Send verification email
|
|
||||||
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
|
||||||
verify_url = f"{app_url}/verify?token={verification_token}"
|
|
||||||
|
|
||||||
email_body = f"""Hallo {prof['name']},
|
|
||||||
|
|
||||||
du hast eine neue Bestätigungs-E-Mail angefordert.
|
|
||||||
|
|
||||||
Bitte bestätige deine E-Mail-Adresse, indem du auf folgenden Link klickst:
|
|
||||||
|
|
||||||
{verify_url}
|
|
||||||
|
|
||||||
Dieser Link ist 24 Stunden gültig.
|
|
||||||
|
|
||||||
Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren.
|
|
||||||
|
|
||||||
Viele Grüße
|
|
||||||
Dein Mitai Jinkendo Team
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_email(
|
|
||||||
to=email,
|
|
||||||
subject="Neue Bestätigungs-E-Mail - Mitai Jinkendo",
|
|
||||||
body=email_body
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to send verification email: {e}")
|
|
||||||
raise HTTPException(500, "E-Mail konnte nicht versendet werden")
|
|
||||||
|
|
||||||
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}
|
|
||||||
|
|
@ -1,412 +0,0 @@
|
||||||
"""
|
|
||||||
Blood Pressure Router - v9d Phase 2d Refactored
|
|
||||||
|
|
||||||
Context-dependent blood pressure measurements (multiple times per day):
|
|
||||||
- Systolic/Diastolic Blood Pressure
|
|
||||||
- Pulse during measurement
|
|
||||||
- Context tagging (morning_fasted, after_meal, before_training, etc.)
|
|
||||||
- Warning flags (irregular heartbeat, AFib)
|
|
||||||
|
|
||||||
Endpoints:
|
|
||||||
- GET /api/blood-pressure List BP measurements
|
|
||||||
- GET /api/blood-pressure/by-date/{date} Get measurements for specific date
|
|
||||||
- POST /api/blood-pressure Create BP measurement
|
|
||||||
- PUT /api/blood-pressure/{id} Update BP measurement
|
|
||||||
- DELETE /api/blood-pressure/{id} Delete BP measurement
|
|
||||||
- GET /api/blood-pressure/stats Statistics and trends
|
|
||||||
- POST /api/blood-pressure/import/omron Import Omron CSV
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Header, UploadFile, File
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import logging
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/blood-pressure", tags=["blood_pressure"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# German month mapping for Omron dates
|
|
||||||
GERMAN_MONTHS = {
|
|
||||||
'Januar': '01', 'Jan.': '01', 'Jan': '01',
|
|
||||||
'Februar': '02', 'Feb.': '02', 'Feb': '02',
|
|
||||||
'März': '03', 'Mär.': '03', 'Mär': '03',
|
|
||||||
'April': '04', 'Apr.': '04', 'Apr': '04',
|
|
||||||
'Mai': '05',
|
|
||||||
'Juni': '06', 'Jun.': '06', 'Jun': '06',
|
|
||||||
'Juli': '07', 'Jul.': '07', 'Jul': '07',
|
|
||||||
'August': '08', 'Aug.': '08', 'Aug': '08',
|
|
||||||
'September': '09', 'Sep.': '09', 'Sep': '09',
|
|
||||||
'Oktober': '10', 'Okt.': '10', 'Okt': '10',
|
|
||||||
'November': '11', 'Nov.': '11', 'Nov': '11',
|
|
||||||
'Dezember': '12', 'Dez.': '12', 'Dez': '12',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# Pydantic Models
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
class BPEntry(BaseModel):
|
|
||||||
measured_at: str # ISO format datetime
|
|
||||||
systolic: int
|
|
||||||
diastolic: int
|
|
||||||
pulse: Optional[int] = None
|
|
||||||
context: Optional[str] = None # morning_fasted, after_meal, etc.
|
|
||||||
irregular_heartbeat: Optional[bool] = False
|
|
||||||
possible_afib: Optional[bool] = False
|
|
||||||
note: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# Helper Functions
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
def parse_omron_date(date_str: str, time_str: str) -> str:
|
|
||||||
"""
|
|
||||||
Parse Omron German date/time format to ISO datetime.
|
|
||||||
Input: "13 März 2026", "08:30"
|
|
||||||
Output: "2026-03-13 08:30:00"
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
parts = date_str.strip().split()
|
|
||||||
if len(parts) != 3:
|
|
||||||
return None
|
|
||||||
|
|
||||||
day = parts[0]
|
|
||||||
month_name = parts[1]
|
|
||||||
year = parts[2]
|
|
||||||
|
|
||||||
month = GERMAN_MONTHS.get(month_name)
|
|
||||||
if not month:
|
|
||||||
return None
|
|
||||||
|
|
||||||
iso_date = f"{year}-{month}-{day.zfill(2)}"
|
|
||||||
iso_datetime = f"{iso_date} {time_str}:00"
|
|
||||||
|
|
||||||
# Validate
|
|
||||||
datetime.fromisoformat(iso_datetime)
|
|
||||||
return iso_datetime
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error parsing Omron date: {date_str} {time_str} - {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# CRUD Endpoints
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_bp_measurements(
|
|
||||||
limit: int = 90,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Get blood pressure measurements (last N entries)."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT * FROM blood_pressure_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
LIMIT %s
|
|
||||||
""", (pid, limit))
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/by-date/{date}")
|
|
||||||
def get_bp_by_date(
|
|
||||||
date: str,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Get all BP measurements for a specific date."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT * FROM blood_pressure_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND DATE(measured_at) = %s
|
|
||||||
ORDER BY measured_at ASC
|
|
||||||
""", (pid, date))
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_bp_measurement(
|
|
||||||
entry: BPEntry,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Create new BP measurement."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO blood_pressure_log (
|
|
||||||
profile_id, measured_at,
|
|
||||||
systolic, diastolic, pulse,
|
|
||||||
context, irregular_heartbeat, possible_afib,
|
|
||||||
note, source
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'manual')
|
|
||||||
RETURNING *
|
|
||||||
""", (
|
|
||||||
pid, entry.measured_at,
|
|
||||||
entry.systolic, entry.diastolic, entry.pulse,
|
|
||||||
entry.context, entry.irregular_heartbeat, entry.possible_afib,
|
|
||||||
entry.note
|
|
||||||
))
|
|
||||||
return r2d(cur.fetchone())
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{entry_id}")
|
|
||||||
def update_bp_measurement(
|
|
||||||
entry_id: int,
|
|
||||||
entry: BPEntry,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Update existing BP measurement."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE blood_pressure_log
|
|
||||||
SET measured_at = %s,
|
|
||||||
systolic = %s,
|
|
||||||
diastolic = %s,
|
|
||||||
pulse = %s,
|
|
||||||
context = %s,
|
|
||||||
irregular_heartbeat = %s,
|
|
||||||
possible_afib = %s,
|
|
||||||
note = %s
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
RETURNING *
|
|
||||||
""", (
|
|
||||||
entry.measured_at,
|
|
||||||
entry.systolic, entry.diastolic, entry.pulse,
|
|
||||||
entry.context, entry.irregular_heartbeat, entry.possible_afib,
|
|
||||||
entry.note,
|
|
||||||
entry_id, pid
|
|
||||||
))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Entry not found")
|
|
||||||
return r2d(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{entry_id}")
|
|
||||||
def delete_bp_measurement(
|
|
||||||
entry_id: int,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Delete BP measurement."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
DELETE FROM blood_pressure_log
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
""", (entry_id, pid))
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
raise HTTPException(404, "Entry not found")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# Statistics & Trends
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def get_bp_stats(
|
|
||||||
days: int = 30,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Get blood pressure statistics and trends."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
cutoff_date = datetime.now() - timedelta(days=days)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total_measurements,
|
|
||||||
-- Overall averages
|
|
||||||
AVG(systolic) as avg_systolic,
|
|
||||||
AVG(diastolic) as avg_diastolic,
|
|
||||||
AVG(pulse) FILTER (WHERE pulse IS NOT NULL) as avg_pulse,
|
|
||||||
-- 7-day averages
|
|
||||||
AVG(systolic) FILTER (WHERE measured_at >= NOW() - INTERVAL '7 days') as avg_systolic_7d,
|
|
||||||
AVG(diastolic) FILTER (WHERE measured_at >= NOW() - INTERVAL '7 days') as avg_diastolic_7d,
|
|
||||||
-- Context-specific averages
|
|
||||||
AVG(systolic) FILTER (WHERE context = 'morning_fasted') as avg_systolic_morning,
|
|
||||||
AVG(diastolic) FILTER (WHERE context = 'morning_fasted') as avg_diastolic_morning,
|
|
||||||
AVG(systolic) FILTER (WHERE context = 'evening') as avg_systolic_evening,
|
|
||||||
AVG(diastolic) FILTER (WHERE context = 'evening') as avg_diastolic_evening,
|
|
||||||
-- Warning flags
|
|
||||||
COUNT(*) FILTER (WHERE irregular_heartbeat = true) as irregular_count,
|
|
||||||
COUNT(*) FILTER (WHERE possible_afib = true) as afib_count
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id = %s AND measured_at >= %s
|
|
||||||
""", (pid, cutoff_date))
|
|
||||||
|
|
||||||
stats = r2d(cur.fetchone())
|
|
||||||
|
|
||||||
# Classify BP ranges (WHO/ISH guidelines)
|
|
||||||
if stats['avg_systolic'] and stats['avg_diastolic']:
|
|
||||||
if stats['avg_systolic'] < 120 and stats['avg_diastolic'] < 80:
|
|
||||||
stats['bp_category'] = 'optimal'
|
|
||||||
elif stats['avg_systolic'] < 130 and stats['avg_diastolic'] < 85:
|
|
||||||
stats['bp_category'] = 'normal'
|
|
||||||
elif stats['avg_systolic'] < 140 and stats['avg_diastolic'] < 90:
|
|
||||||
stats['bp_category'] = 'high_normal'
|
|
||||||
elif stats['avg_systolic'] < 160 and stats['avg_diastolic'] < 100:
|
|
||||||
stats['bp_category'] = 'grade_1_hypertension'
|
|
||||||
elif stats['avg_systolic'] < 180 and stats['avg_diastolic'] < 110:
|
|
||||||
stats['bp_category'] = 'grade_2_hypertension'
|
|
||||||
else:
|
|
||||||
stats['bp_category'] = 'grade_3_hypertension'
|
|
||||||
else:
|
|
||||||
stats['bp_category'] = None
|
|
||||||
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
# Import: Omron CSV
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
@router.post("/import/omron")
|
|
||||||
async def import_omron_csv(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Import blood pressure measurements from Omron CSV export."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
content = await file.read()
|
|
||||||
decoded = content.decode('utf-8')
|
|
||||||
reader = csv.DictReader(io.StringIO(decoded))
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
updated = 0
|
|
||||||
skipped = 0
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Log available columns for debugging
|
|
||||||
first_row = True
|
|
||||||
|
|
||||||
for row in reader:
|
|
||||||
try:
|
|
||||||
if first_row:
|
|
||||||
logger.info(f"Omron CSV Columns: {list(row.keys())}")
|
|
||||||
first_row = False
|
|
||||||
|
|
||||||
# Parse Omron German date format
|
|
||||||
date_str = row.get('Datum', row.get('Date'))
|
|
||||||
time_str = row.get('Zeit', row.get('Time', '08:00'))
|
|
||||||
|
|
||||||
if not date_str:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
measured_at = parse_omron_date(date_str, time_str)
|
|
||||||
if not measured_at:
|
|
||||||
errors += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract measurements (support column names with/without units)
|
|
||||||
systolic = (row.get('Systolisch (mmHg)') or row.get('Systolisch') or
|
|
||||||
row.get('Systolic (mmHg)') or row.get('Systolic'))
|
|
||||||
diastolic = (row.get('Diastolisch (mmHg)') or row.get('Diastolisch') or
|
|
||||||
row.get('Diastolic (mmHg)') or row.get('Diastolic'))
|
|
||||||
pulse = (row.get('Puls (bpm)') or row.get('Puls') or
|
|
||||||
row.get('Pulse (bpm)') or row.get('Pulse'))
|
|
||||||
|
|
||||||
if not systolic or not diastolic:
|
|
||||||
logger.warning(f"Skipped row {date_str} {time_str}: Missing BP values (sys={systolic}, dia={diastolic})")
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Parse warning flags (support various column names)
|
|
||||||
irregular = (row.get('Unregelmäßiger Herzschlag festgestellt') or
|
|
||||||
row.get('Unregelmäßiger Herzschlag') or
|
|
||||||
row.get('Irregular Heartbeat') or '')
|
|
||||||
afib = (row.get('Mögliches AFib') or
|
|
||||||
row.get('Vorhofflimmern') or
|
|
||||||
row.get('Possible AFib') or
|
|
||||||
row.get('AFib') or '')
|
|
||||||
|
|
||||||
irregular_heartbeat = irregular.lower() in ['ja', 'yes', 'true', '1']
|
|
||||||
possible_afib = afib.lower() in ['ja', 'yes', 'true', '1']
|
|
||||||
|
|
||||||
# Determine context based on time
|
|
||||||
hour = int(time_str.split(':')[0])
|
|
||||||
if 5 <= hour < 10:
|
|
||||||
context = 'morning_fasted'
|
|
||||||
elif 18 <= hour < 23:
|
|
||||||
context = 'evening'
|
|
||||||
else:
|
|
||||||
context = 'other'
|
|
||||||
|
|
||||||
# Upsert
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO blood_pressure_log (
|
|
||||||
profile_id, measured_at,
|
|
||||||
systolic, diastolic, pulse,
|
|
||||||
context, irregular_heartbeat, possible_afib,
|
|
||||||
source
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'omron')
|
|
||||||
ON CONFLICT (profile_id, measured_at)
|
|
||||||
DO UPDATE SET
|
|
||||||
systolic = EXCLUDED.systolic,
|
|
||||||
diastolic = EXCLUDED.diastolic,
|
|
||||||
pulse = EXCLUDED.pulse,
|
|
||||||
context = EXCLUDED.context,
|
|
||||||
irregular_heartbeat = EXCLUDED.irregular_heartbeat,
|
|
||||||
possible_afib = EXCLUDED.possible_afib
|
|
||||||
WHERE blood_pressure_log.source != 'manual'
|
|
||||||
RETURNING (xmax = 0) AS inserted
|
|
||||||
""", (
|
|
||||||
pid, measured_at,
|
|
||||||
int(systolic), int(diastolic),
|
|
||||||
int(pulse) if pulse else None,
|
|
||||||
context, irregular_heartbeat, possible_afib
|
|
||||||
))
|
|
||||||
|
|
||||||
result = cur.fetchone()
|
|
||||||
if result is None:
|
|
||||||
# WHERE clause prevented update (manual entry exists)
|
|
||||||
skipped += 1
|
|
||||||
elif result['inserted']:
|
|
||||||
inserted += 1
|
|
||||||
else:
|
|
||||||
updated += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error importing Omron row: {e}")
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"inserted": inserted,
|
|
||||||
"updated": updated,
|
|
||||||
"skipped": skipped,
|
|
||||||
"errors": errors
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
"""
|
|
||||||
Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles body fat measurements via skinfold caliper (4 methods supported).
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, Depends, HTTPException
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
|
||||||
from models import CaliperEntry
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
from feature_logger import log_feature_usage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/caliper", tags=["caliper"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get caliper entries for current profile."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Create or update caliper entry (upsert by date)."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'caliper_entries')
|
|
||||||
log_feature_usage(pid, 'caliper_entries', access, 'create')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"caliper_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für Caliper-Einträge überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
|
||||||
ex = cur.fetchone()
|
|
||||||
d = e.model_dump()
|
|
||||||
is_new_entry = not ex
|
|
||||||
|
|
||||||
if ex:
|
|
||||||
# UPDATE existing entry
|
|
||||||
eid = ex['id']
|
|
||||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
|
||||||
cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
|
|
||||||
[v for k,v in d.items() if k!='date']+[eid])
|
|
||||||
else:
|
|
||||||
# INSERT new entry
|
|
||||||
eid = str(uuid.uuid4())
|
|
||||||
cur.execute("""INSERT INTO caliper_log
|
|
||||||
(id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
|
|
||||||
sf_abdomen,sf_thigh,sf_calf_med,sf_lowerback,sf_biceps,body_fat_pct,lean_mass,fat_mass,notes,created)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
||||||
(eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
|
|
||||||
d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
|
|
||||||
d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter (only for new entries)
|
|
||||||
increment_feature_usage(pid, 'caliper_entries')
|
|
||||||
|
|
||||||
return {"id":eid,"date":e.date}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{eid}")
|
|
||||||
def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Update existing caliper entry."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
d = e.model_dump()
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
|
||||||
list(d.values())+[eid,pid])
|
|
||||||
return {"id":eid}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{eid}")
|
|
||||||
def delete_caliper(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Delete caliper entry."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("DELETE FROM caliper_log WHERE id=%s AND profile_id=%s", (eid,pid))
|
|
||||||
return {"ok":True}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,100 +0,0 @@
|
||||||
"""
|
|
||||||
Circumference Tracking Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles body circumference measurements (8 measurement points).
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Header, Depends, HTTPException
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
|
||||||
from models import CircumferenceEntry
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
from feature_logger import log_feature_usage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get circumference entries for current profile."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Create or update circumference entry (upsert by date)."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'circumference_entries')
|
|
||||||
log_feature_usage(pid, 'circumference_entries', access, 'create')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"circumference_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für Umfangs-Einträge überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
|
||||||
ex = cur.fetchone()
|
|
||||||
d = e.model_dump()
|
|
||||||
is_new_entry = not ex
|
|
||||||
|
|
||||||
if ex:
|
|
||||||
# UPDATE existing entry
|
|
||||||
eid = ex['id']
|
|
||||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
|
||||||
cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
|
|
||||||
[v for k,v in d.items() if k!='date']+[eid])
|
|
||||||
else:
|
|
||||||
# INSERT new entry
|
|
||||||
eid = str(uuid.uuid4())
|
|
||||||
cur.execute("""INSERT INTO circumference_log
|
|
||||||
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
||||||
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
|
|
||||||
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter (only for new entries)
|
|
||||||
increment_feature_usage(pid, 'circumference_entries')
|
|
||||||
|
|
||||||
return {"id":eid,"date":e.date}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{eid}")
|
|
||||||
def update_circ(eid: str, e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Update existing circumference entry."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
d = e.model_dump()
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(f"UPDATE circumference_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
|
||||||
list(d.values())+[eid,pid])
|
|
||||||
return {"id":eid}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{eid}")
|
|
||||||
def delete_circ(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Delete circumference entry."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("DELETE FROM circumference_log WHERE id=%s AND profile_id=%s", (eid,pid))
|
|
||||||
return {"ok":True}
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
"""
|
|
||||||
Coupon Management Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles coupon CRUD (admin) and redemption (users).
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, require_admin
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/coupons", tags=["coupons"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_coupons(session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: List all coupons with redemption stats."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
c.*,
|
|
||||||
t.name as tier_name,
|
|
||||||
(SELECT COUNT(*) FROM coupon_redemptions WHERE coupon_id = c.id) as redemptions
|
|
||||||
FROM coupons c
|
|
||||||
LEFT JOIN tiers t ON t.id = c.tier_id
|
|
||||||
ORDER BY c.created DESC
|
|
||||||
""")
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_coupon(data: dict, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Admin: Create new coupon.
|
|
||||||
|
|
||||||
Required fields:
|
|
||||||
- code: Unique coupon code
|
|
||||||
- type: 'single_use', 'period', or 'wellpass'
|
|
||||||
- tier_id: Target tier
|
|
||||||
- duration_days: For period/wellpass coupons
|
|
||||||
|
|
||||||
Optional fields:
|
|
||||||
- max_redemptions: NULL = unlimited
|
|
||||||
- valid_from, valid_until: Validity period
|
|
||||||
- description: Internal note
|
|
||||||
"""
|
|
||||||
code = data.get('code', '').strip().upper()
|
|
||||||
coupon_type = data.get('type')
|
|
||||||
tier_id = data.get('tier_id')
|
|
||||||
duration_days = data.get('duration_days')
|
|
||||||
max_redemptions = data.get('max_redemptions')
|
|
||||||
valid_from = data.get('valid_from')
|
|
||||||
valid_until = data.get('valid_until')
|
|
||||||
description = data.get('description', '')
|
|
||||||
|
|
||||||
if not code:
|
|
||||||
raise HTTPException(400, "Coupon-Code fehlt")
|
|
||||||
if coupon_type not in ['single_use', 'period', 'wellpass']:
|
|
||||||
raise HTTPException(400, "Ungültiger Coupon-Typ")
|
|
||||||
if not tier_id:
|
|
||||||
raise HTTPException(400, "Tier fehlt")
|
|
||||||
if coupon_type in ['period', 'wellpass'] and not duration_days:
|
|
||||||
raise HTTPException(400, "duration_days fehlt für period/wellpass Coupons")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check if code already exists
|
|
||||||
cur.execute("SELECT id FROM coupons WHERE code = %s", (code,))
|
|
||||||
if cur.fetchone():
|
|
||||||
raise HTTPException(400, f"Coupon-Code '{code}' existiert bereits")
|
|
||||||
|
|
||||||
# Create coupon
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO coupons (
|
|
||||||
code, type, tier_id, duration_days, max_redemptions,
|
|
||||||
valid_from, valid_until, description, created_by
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
code, coupon_type, tier_id, duration_days, max_redemptions,
|
|
||||||
valid_from, valid_until, description, session['profile_id']
|
|
||||||
))
|
|
||||||
|
|
||||||
coupon_id = cur.fetchone()['id']
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {"ok": True, "id": coupon_id, "code": code}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{coupon_id}")
|
|
||||||
def update_coupon(coupon_id: str, data: dict, session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: Update coupon."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
if 'active' in data:
|
|
||||||
updates.append('active = %s')
|
|
||||||
values.append(data['active'])
|
|
||||||
if 'max_redemptions' in data:
|
|
||||||
updates.append('max_redemptions = %s')
|
|
||||||
values.append(data['max_redemptions'])
|
|
||||||
if 'valid_until' in data:
|
|
||||||
updates.append('valid_until = %s')
|
|
||||||
values.append(data['valid_until'])
|
|
||||||
if 'description' in data:
|
|
||||||
updates.append('description = %s')
|
|
||||||
values.append(data['description'])
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
updates.append('updated = CURRENT_TIMESTAMP')
|
|
||||||
values.append(coupon_id)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE coupons SET {', '.join(updates)} WHERE id = %s",
|
|
||||||
values
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{coupon_id}")
|
|
||||||
def delete_coupon(coupon_id: str, session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: Delete coupon (soft-delete: set active=false)."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("UPDATE coupons SET active = false WHERE id = %s", (coupon_id,))
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{coupon_id}/redemptions")
|
|
||||||
def get_coupon_redemptions(coupon_id: str, session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: Get all redemptions for a coupon."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
cr.id,
|
|
||||||
cr.redeemed_at,
|
|
||||||
p.name as profile_name,
|
|
||||||
p.email as profile_email,
|
|
||||||
ag.valid_from,
|
|
||||||
ag.valid_until,
|
|
||||||
ag.is_active
|
|
||||||
FROM coupon_redemptions cr
|
|
||||||
JOIN profiles p ON p.id = cr.profile_id
|
|
||||||
LEFT JOIN access_grants ag ON ag.id = cr.access_grant_id
|
|
||||||
WHERE cr.coupon_id = %s
|
|
||||||
ORDER BY cr.redeemed_at DESC
|
|
||||||
""", (coupon_id,))
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/redeem")
|
|
||||||
def redeem_coupon(data: dict, session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
User: Redeem a coupon code.
|
|
||||||
|
|
||||||
Creates an access_grant and handles Wellpass pause/resume logic.
|
|
||||||
"""
|
|
||||||
code = data.get('code', '').strip().upper()
|
|
||||||
if not code:
|
|
||||||
raise HTTPException(400, "Coupon-Code fehlt")
|
|
||||||
|
|
||||||
profile_id = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get coupon
|
|
||||||
cur.execute("""
|
|
||||||
SELECT * FROM coupons
|
|
||||||
WHERE code = %s AND active = true
|
|
||||||
""", (code,))
|
|
||||||
coupon = cur.fetchone()
|
|
||||||
|
|
||||||
if not coupon:
|
|
||||||
raise HTTPException(404, "Ungültiger Coupon-Code")
|
|
||||||
|
|
||||||
# Check validity period
|
|
||||||
now = datetime.now()
|
|
||||||
if coupon['valid_from'] and now < coupon['valid_from']:
|
|
||||||
raise HTTPException(400, "Coupon noch nicht gültig")
|
|
||||||
if coupon['valid_until'] and now > coupon['valid_until']:
|
|
||||||
raise HTTPException(400, "Coupon abgelaufen")
|
|
||||||
|
|
||||||
# Check max redemptions
|
|
||||||
if coupon['max_redemptions'] is not None:
|
|
||||||
if coupon['redemption_count'] >= coupon['max_redemptions']:
|
|
||||||
raise HTTPException(400, "Coupon bereits vollständig eingelöst")
|
|
||||||
|
|
||||||
# Check if user already redeemed this coupon
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id FROM coupon_redemptions
|
|
||||||
WHERE coupon_id = %s AND profile_id = %s
|
|
||||||
""", (coupon['id'], profile_id))
|
|
||||||
if cur.fetchone():
|
|
||||||
raise HTTPException(400, "Du hast diesen Coupon bereits eingelöst")
|
|
||||||
|
|
||||||
# Create access grant
|
|
||||||
valid_from = now
|
|
||||||
valid_until = now + timedelta(days=coupon['duration_days']) if coupon['duration_days'] else None
|
|
||||||
|
|
||||||
# Wellpass logic: Pause existing personal grants
|
|
||||||
if coupon['type'] == 'wellpass':
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, valid_until
|
|
||||||
FROM access_grants
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND is_active = true
|
|
||||||
AND granted_by != 'wellpass'
|
|
||||||
AND valid_until > CURRENT_TIMESTAMP
|
|
||||||
""", (profile_id,))
|
|
||||||
active_grants = cur.fetchall()
|
|
||||||
|
|
||||||
for grant in active_grants:
|
|
||||||
# Calculate remaining days
|
|
||||||
remaining = (grant['valid_until'] - now).days
|
|
||||||
# Pause grant
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE access_grants
|
|
||||||
SET is_active = false,
|
|
||||||
paused_at = CURRENT_TIMESTAMP,
|
|
||||||
remaining_days = %s
|
|
||||||
WHERE id = %s
|
|
||||||
""", (remaining, grant['id']))
|
|
||||||
|
|
||||||
# Insert access grant
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO access_grants (
|
|
||||||
profile_id, tier_id, granted_by, coupon_id,
|
|
||||||
valid_from, valid_until, is_active
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, true)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
profile_id, coupon['tier_id'],
|
|
||||||
coupon['type'], coupon['id'],
|
|
||||||
valid_from, valid_until
|
|
||||||
))
|
|
||||||
grant_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
# Record redemption
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO coupon_redemptions (coupon_id, profile_id, access_grant_id)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
""", (coupon['id'], profile_id, grant_id))
|
|
||||||
|
|
||||||
# Increment coupon redemption count
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE coupons
|
|
||||||
SET redemption_count = redemption_count + 1
|
|
||||||
WHERE id = %s
|
|
||||||
""", (coupon['id'],))
|
|
||||||
|
|
||||||
# Log activity
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO user_activity_log (profile_id, action, details)
|
|
||||||
VALUES (%s, 'coupon_redeemed', %s)
|
|
||||||
""", (
|
|
||||||
profile_id,
|
|
||||||
f'{{"coupon_code": "{code}", "tier": "{coupon["tier_id"]}", "duration_days": {coupon["duration_days"]}}}'
|
|
||||||
))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"message": f"Coupon erfolgreich eingelöst: {coupon['tier_id']} für {coupon['duration_days']} Tage",
|
|
||||||
"grant_id": grant_id,
|
|
||||||
"valid_until": valid_until.isoformat() if valid_until else None
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
"""
|
|
||||||
Evaluation Endpoints - Training Type Profiles
|
|
||||||
Endpoints for activity evaluation and re-evaluation.
|
|
||||||
|
|
||||||
Issue: #15
|
|
||||||
Date: 2026-03-23
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, require_admin
|
|
||||||
from evaluation_helper import (
|
|
||||||
evaluate_and_save_activity,
|
|
||||||
batch_evaluate_activities,
|
|
||||||
load_parameters_registry
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/evaluation", tags=["evaluation"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/parameters")
|
|
||||||
def list_parameters(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
List all available training parameters.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
parameters = load_parameters_registry(cur)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"parameters": list(parameters.values()),
|
|
||||||
"count": len(parameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/activity/{activity_id}")
|
|
||||||
def evaluate_activity(
|
|
||||||
activity_id: str,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Evaluates or re-evaluates a single activity.
|
|
||||||
|
|
||||||
Returns the evaluation result.
|
|
||||||
"""
|
|
||||||
profile_id = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Load activity
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
|
||||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
|
||||||
rpe, pace_min_per_km, cadence, elevation_gain
|
|
||||||
FROM activity_log
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
""", (activity_id, profile_id))
|
|
||||||
|
|
||||||
activity = cur.fetchone()
|
|
||||||
if not activity:
|
|
||||||
raise HTTPException(404, "Activity not found")
|
|
||||||
|
|
||||||
activity_dict = dict(activity)
|
|
||||||
|
|
||||||
# Evaluate
|
|
||||||
result = evaluate_and_save_activity(
|
|
||||||
cur,
|
|
||||||
activity_dict["id"],
|
|
||||||
activity_dict,
|
|
||||||
activity_dict["training_type_id"],
|
|
||||||
profile_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
return {
|
|
||||||
"message": "No profile configured for this training type",
|
|
||||||
"evaluation": None
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Activity evaluated",
|
|
||||||
"evaluation": result
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
|
||||||
def batch_evaluate(
|
|
||||||
limit: Optional[int] = None,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Re-evaluates all activities for the current user.
|
|
||||||
|
|
||||||
Optional limit parameter for testing.
|
|
||||||
"""
|
|
||||||
profile_id = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
stats = batch_evaluate_activities(cur, profile_id, limit)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Batch evaluation completed",
|
|
||||||
"stats": stats
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch/all")
|
|
||||||
def batch_evaluate_all(session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Admin-only: Re-evaluates all activities for all users.
|
|
||||||
|
|
||||||
Use with caution on large databases!
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get all profiles
|
|
||||||
cur.execute("SELECT id FROM profiles")
|
|
||||||
profiles = cur.fetchall()
|
|
||||||
|
|
||||||
total_stats = {
|
|
||||||
"profiles": len(profiles),
|
|
||||||
"total": 0,
|
|
||||||
"evaluated": 0,
|
|
||||||
"skipped": 0,
|
|
||||||
"errors": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for profile in profiles:
|
|
||||||
profile_id = profile['id']
|
|
||||||
stats = batch_evaluate_activities(cur, profile_id)
|
|
||||||
|
|
||||||
total_stats["total"] += stats["total"]
|
|
||||||
total_stats["evaluated"] += stats["evaluated"]
|
|
||||||
total_stats["skipped"] += stats["skipped"]
|
|
||||||
total_stats["errors"] += stats["errors"]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Batch evaluation for all users completed",
|
|
||||||
"stats": total_stats
|
|
||||||
}
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
"""
|
|
||||||
Data Export Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles CSV, JSON, and ZIP exports with photos.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
|
||||||
from fastapi.responses import StreamingResponse, Response
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
from feature_logger import log_feature_usage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/csv")
|
|
||||||
def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Export all data as CSV."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'data_export')
|
|
||||||
log_feature_usage(pid, 'data_export', access, 'export_csv')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build CSV
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
|
|
||||||
# Header
|
|
||||||
writer.writerow(["Typ", "Datum", "Wert", "Details"])
|
|
||||||
|
|
||||||
# Weight
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
writer.writerow(["Gewicht", r['date'], f"{float(r['weight'])}kg", r['note'] or ""])
|
|
||||||
|
|
||||||
# Circumferences
|
|
||||||
cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
details = f"Taille:{float(r['c_waist'])}cm Bauch:{float(r['c_belly'])}cm Hüfte:{float(r['c_hip'])}cm"
|
|
||||||
writer.writerow(["Umfänge", r['date'], "", details])
|
|
||||||
|
|
||||||
# Caliper
|
|
||||||
cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"])
|
|
||||||
|
|
||||||
# Nutrition
|
|
||||||
cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
|
|
||||||
|
|
||||||
# Activity
|
|
||||||
cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
|
||||||
|
|
||||||
output.seek(0)
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter
|
|
||||||
increment_feature_usage(pid, 'data_export')
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([output.getvalue()]),
|
|
||||||
media_type="text/csv",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/json")
|
|
||||||
def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Export all data as JSON."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'data_export')
|
|
||||||
log_feature_usage(pid, 'data_export', access, 'export_json')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collect all data
|
|
||||||
data = {}
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
||||||
data['profile'] = r2d(cur.fetchone())
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
data['weight'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
data['circumferences'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
data['caliper'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
data['nutrition'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
data['activity'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
|
||||||
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
def decimal_handler(obj):
|
|
||||||
if isinstance(obj, Decimal):
|
|
||||||
return float(obj)
|
|
||||||
return str(obj)
|
|
||||||
|
|
||||||
json_str = json.dumps(data, indent=2, default=decimal_handler)
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter
|
|
||||||
increment_feature_usage(pid, 'data_export')
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
content=json_str,
|
|
||||||
media_type="application/json",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/zip")
|
|
||||||
def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Export all data as ZIP (CSV + JSON + photos) per specification."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'data_export')
|
|
||||||
log_feature_usage(pid, 'data_export', access, 'export_zip')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get profile
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
||||||
prof = r2d(cur.fetchone())
|
|
||||||
|
|
||||||
# Helper: CSV writer with UTF-8 BOM + semicolon
|
|
||||||
def write_csv(zf, filename, rows, columns):
|
|
||||||
if not rows:
|
|
||||||
return
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output, delimiter=';')
|
|
||||||
writer.writerow(columns)
|
|
||||||
for r in rows:
|
|
||||||
writer.writerow([
|
|
||||||
'' if r.get(col) is None else
|
|
||||||
(float(r[col]) if isinstance(r.get(col), Decimal) else r[col])
|
|
||||||
for col in columns
|
|
||||||
])
|
|
||||||
# UTF-8 with BOM for Excel
|
|
||||||
csv_bytes = '\ufeff'.encode('utf-8') + output.getvalue().encode('utf-8')
|
|
||||||
zf.writestr(f"data/{filename}", csv_bytes)
|
|
||||||
|
|
||||||
# Create ZIP
|
|
||||||
zip_buffer = io.BytesIO()
|
|
||||||
export_date = datetime.now().strftime('%Y-%m-%d')
|
|
||||||
profile_name = prof.get('name', 'export')
|
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# 1. README.txt
|
|
||||||
readme = f"""Mitai Jinkendo – Datenexport
|
|
||||||
Version: 2
|
|
||||||
Exportiert am: {export_date}
|
|
||||||
Profil: {profile_name}
|
|
||||||
|
|
||||||
Inhalt:
|
|
||||||
- profile.json: Profildaten und Einstellungen
|
|
||||||
- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8)
|
|
||||||
- insights/: KI-Auswertungen (JSON)
|
|
||||||
- photos/: Progress-Fotos (JPEG)
|
|
||||||
|
|
||||||
Import:
|
|
||||||
Dieser Export kann in Mitai Jinkendo unter
|
|
||||||
Einstellungen → Import → "Mitai Backup importieren"
|
|
||||||
wieder eingespielt werden.
|
|
||||||
|
|
||||||
Format-Version 2 (ab v9b):
|
|
||||||
Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
|
|
||||||
Trennzeichen: Semikolon (;)
|
|
||||||
Datumsformat: YYYY-MM-DD
|
|
||||||
"""
|
|
||||||
zf.writestr("README.txt", readme.encode('utf-8'))
|
|
||||||
|
|
||||||
# 2. profile.json (ohne Passwort-Hash)
|
|
||||||
cur.execute("SELECT COUNT(*) as c FROM weight_log WHERE profile_id=%s", (pid,))
|
|
||||||
w_count = cur.fetchone()['c']
|
|
||||||
cur.execute("SELECT COUNT(*) as c FROM nutrition_log WHERE profile_id=%s", (pid,))
|
|
||||||
n_count = cur.fetchone()['c']
|
|
||||||
cur.execute("SELECT COUNT(*) as c FROM activity_log WHERE profile_id=%s", (pid,))
|
|
||||||
a_count = cur.fetchone()['c']
|
|
||||||
cur.execute("SELECT COUNT(*) as c FROM photos WHERE profile_id=%s", (pid,))
|
|
||||||
p_count = cur.fetchone()['c']
|
|
||||||
|
|
||||||
profile_data = {
|
|
||||||
"export_version": "2",
|
|
||||||
"export_date": export_date,
|
|
||||||
"app": "Mitai Jinkendo",
|
|
||||||
"profile": {
|
|
||||||
"name": prof.get('name'),
|
|
||||||
"email": prof.get('email'),
|
|
||||||
"sex": prof.get('sex'),
|
|
||||||
"height": float(prof['height']) if prof.get('height') else None,
|
|
||||||
"birth_year": prof['dob'].year if prof.get('dob') else None,
|
|
||||||
"goal_weight": float(prof['goal_weight']) if prof.get('goal_weight') else None,
|
|
||||||
"goal_bf_pct": float(prof['goal_bf_pct']) if prof.get('goal_bf_pct') else None,
|
|
||||||
"avatar_color": prof.get('avatar_color'),
|
|
||||||
"auth_type": prof.get('auth_type'),
|
|
||||||
"session_days": prof.get('session_days'),
|
|
||||||
"ai_enabled": prof.get('ai_enabled'),
|
|
||||||
"tier": prof.get('tier')
|
|
||||||
},
|
|
||||||
"stats": {
|
|
||||||
"weight_entries": w_count,
|
|
||||||
"nutrition_entries": n_count,
|
|
||||||
"activity_entries": a_count,
|
|
||||||
"photos": p_count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zf.writestr("profile.json", json.dumps(profile_data, indent=2, ensure_ascii=False).encode('utf-8'))
|
|
||||||
|
|
||||||
# 3-7. CSV exports (weight, circumferences, caliper, nutrition, activity)
|
|
||||||
cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created'])
|
|
||||||
|
|
||||||
cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
for r in rows:
|
|
||||||
r['waist'] = r.pop('c_waist', None); r['hip'] = r.pop('c_hip', None)
|
|
||||||
r['chest'] = r.pop('c_chest', None); r['neck'] = r.pop('c_neck', None)
|
|
||||||
r['upper_arm'] = r.pop('c_arm', None); r['thigh'] = r.pop('c_thigh', None)
|
|
||||||
r['calf'] = r.pop('c_calf', None); r['forearm'] = None; r['note'] = r.pop('notes', None)
|
|
||||||
write_csv(zf, "circumferences.csv", rows, ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created'])
|
|
||||||
|
|
||||||
cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
for r in rows:
|
|
||||||
r['chest'] = r.pop('sf_chest', None); r['abdomen'] = r.pop('sf_abdomen', None)
|
|
||||||
r['thigh'] = r.pop('sf_thigh', None); r['tricep'] = r.pop('sf_triceps', None)
|
|
||||||
r['subscapular'] = r.pop('sf_subscap', None); r['suprailiac'] = r.pop('sf_suprailiac', None)
|
|
||||||
r['midaxillary'] = r.pop('sf_axilla', None); r['method'] = r.pop('sf_method', None)
|
|
||||||
r['bf_percent'] = r.pop('body_fat_pct', None); r['note'] = r.pop('notes', None)
|
|
||||||
write_csv(zf, "caliper.csv", rows, ['id','date','chest','abdomen','thigh','tricep','subscapular','suprailiac','midaxillary','method','bf_percent','note','created'])
|
|
||||||
|
|
||||||
cur.execute("SELECT id, date, kcal, protein_g, fat_g, carbs_g, source, created FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
for r in rows:
|
|
||||||
r['meal_name'] = ''; r['protein'] = r.pop('protein_g', None)
|
|
||||||
r['fat'] = r.pop('fat_g', None); r['carbs'] = r.pop('carbs_g', None)
|
|
||||||
r['fiber'] = None; r['note'] = ''
|
|
||||||
write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
|
|
||||||
|
|
||||||
cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
for r in rows:
|
|
||||||
r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None)
|
|
||||||
r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None)
|
|
||||||
r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None)
|
|
||||||
write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created'])
|
|
||||||
|
|
||||||
# 8. insights/ai_insights.json
|
|
||||||
cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
|
||||||
insights = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rd = r2d(r)
|
|
||||||
insights.append({
|
|
||||||
"id": rd['id'],
|
|
||||||
"scope": rd['scope'],
|
|
||||||
"created": rd['created'].isoformat() if hasattr(rd['created'], 'isoformat') else str(rd['created']),
|
|
||||||
"result": rd['content']
|
|
||||||
})
|
|
||||||
if insights:
|
|
||||||
zf.writestr("insights/ai_insights.json", json.dumps(insights, indent=2, ensure_ascii=False).encode('utf-8'))
|
|
||||||
|
|
||||||
# 9. photos/
|
|
||||||
cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
|
|
||||||
photos = [r2d(r) for r in cur.fetchall()]
|
|
||||||
for i, photo in enumerate(photos):
|
|
||||||
photo_path = Path(PHOTOS_DIR) / photo['path']
|
|
||||||
if photo_path.exists():
|
|
||||||
filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
|
|
||||||
zf.write(photo_path, f"photos/{filename}")
|
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
|
||||||
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter
|
|
||||||
increment_feature_usage(pid, 'data_export')
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([zip_buffer.getvalue()]),
|
|
||||||
media_type="application/zip",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
|
||||||
)
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
"""
|
|
||||||
Feature Management Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Admin-only CRUD for features registry.
|
|
||||||
User endpoint for feature usage overview (Phase 3).
|
|
||||||
"""
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_admin, require_auth, check_feature_access
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/features", tags=["features"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def list_features(session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: List all features."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT * FROM features
|
|
||||||
ORDER BY category, name
|
|
||||||
""")
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def create_feature(data: dict, session: dict = Depends(require_admin)):
|
|
||||||
"""
|
|
||||||
Admin: Create new feature.
|
|
||||||
|
|
||||||
Required fields:
|
|
||||||
- id: Feature ID (e.g., 'new_data_source')
|
|
||||||
- name: Display name
|
|
||||||
- category: 'data', 'ai', 'export', 'integration'
|
|
||||||
- limit_type: 'count' or 'boolean'
|
|
||||||
- reset_period: 'never', 'daily', 'monthly'
|
|
||||||
- default_limit: INT or NULL (unlimited)
|
|
||||||
"""
|
|
||||||
feature_id = data.get('id', '').strip()
|
|
||||||
name = data.get('name', '').strip()
|
|
||||||
description = data.get('description', '')
|
|
||||||
category = data.get('category')
|
|
||||||
limit_type = data.get('limit_type', 'count')
|
|
||||||
reset_period = data.get('reset_period', 'never')
|
|
||||||
default_limit = data.get('default_limit')
|
|
||||||
|
|
||||||
if not feature_id or not name:
|
|
||||||
raise HTTPException(400, "ID und Name fehlen")
|
|
||||||
if category not in ['data', 'ai', 'export', 'integration']:
|
|
||||||
raise HTTPException(400, "Ungültige Kategorie")
|
|
||||||
if limit_type not in ['count', 'boolean']:
|
|
||||||
raise HTTPException(400, "limit_type muss 'count' oder 'boolean' sein")
|
|
||||||
if reset_period not in ['never', 'daily', 'monthly']:
|
|
||||||
raise HTTPException(400, "Ungültiger reset_period")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check if ID already exists
|
|
||||||
cur.execute("SELECT id FROM features WHERE id = %s", (feature_id,))
|
|
||||||
if cur.fetchone():
|
|
||||||
raise HTTPException(400, f"Feature '{feature_id}' existiert bereits")
|
|
||||||
|
|
||||||
# Create feature
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO features (
|
|
||||||
id, name, description, category, limit_type, reset_period, default_limit
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
||||||
""", (feature_id, name, description, category, limit_type, reset_period, default_limit))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {"ok": True, "id": feature_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{feature_id}")
|
|
||||||
def update_feature(feature_id: str, data: dict, session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: Update feature."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
if 'name' in data:
|
|
||||||
updates.append('name = %s')
|
|
||||||
values.append(data['name'])
|
|
||||||
if 'description' in data:
|
|
||||||
updates.append('description = %s')
|
|
||||||
values.append(data['description'])
|
|
||||||
if 'default_limit' in data:
|
|
||||||
updates.append('default_limit = %s')
|
|
||||||
values.append(data['default_limit'])
|
|
||||||
if 'active' in data:
|
|
||||||
updates.append('active = %s')
|
|
||||||
values.append(data['active'])
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
updates.append('updated = CURRENT_TIMESTAMP')
|
|
||||||
values.append(feature_id)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE features SET {', '.join(updates)} WHERE id = %s",
|
|
||||||
values
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{feature_id}")
|
|
||||||
def delete_feature(feature_id: str, session: dict = Depends(require_admin)):
|
|
||||||
"""Admin: Delete feature (soft-delete: set active=false)."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,))
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{feature_id}/check-access")
|
|
||||||
def check_access(feature_id: str, session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
User: Check if current user can access a feature.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- allowed: bool - whether user can use the feature
|
|
||||||
- limit: int|null - total limit (null = unlimited)
|
|
||||||
- used: int - current usage
|
|
||||||
- remaining: int|null - remaining uses (null = unlimited)
|
|
||||||
- reason: str - why access is granted/denied
|
|
||||||
"""
|
|
||||||
profile_id = session['profile_id']
|
|
||||||
result = check_feature_access(profile_id, feature_id)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage")
|
|
||||||
def get_feature_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
User: Get usage overview for all active features (Phase 3: Frontend Display).
|
|
||||||
|
|
||||||
Returns list of all features with current usage, limits, and reset info.
|
|
||||||
Automatically includes new features from database - no code changes needed.
|
|
||||||
|
|
||||||
Response:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"feature_id": "weight_entries",
|
|
||||||
"name": "Gewichtseinträge",
|
|
||||||
"description": "Anzahl der Gewichtseinträge",
|
|
||||||
"category": "data",
|
|
||||||
"limit_type": "count",
|
|
||||||
"reset_period": "never",
|
|
||||||
"used": 5,
|
|
||||||
"limit": 10,
|
|
||||||
"remaining": 5,
|
|
||||||
"allowed": true,
|
|
||||||
"reset_at": null
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get all active features (dynamic - picks up new features automatically)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, name, description, category, limit_type, reset_period
|
|
||||||
FROM features
|
|
||||||
WHERE active = true
|
|
||||||
ORDER BY category, name
|
|
||||||
""")
|
|
||||||
features = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for feature in features:
|
|
||||||
# Use existing check_feature_access to get usage and limits
|
|
||||||
# This respects user overrides, tier limits, and feature defaults
|
|
||||||
# Pass connection to avoid pool exhaustion
|
|
||||||
access = check_feature_access(pid, feature['id'], conn)
|
|
||||||
|
|
||||||
# Get reset date from user_feature_usage
|
|
||||||
cur.execute("""
|
|
||||||
SELECT reset_at
|
|
||||||
FROM user_feature_usage
|
|
||||||
WHERE profile_id = %s AND feature_id = %s
|
|
||||||
""", (pid, feature['id']))
|
|
||||||
usage_row = cur.fetchone()
|
|
||||||
|
|
||||||
# Format reset_at as ISO string
|
|
||||||
reset_at = None
|
|
||||||
if usage_row and usage_row['reset_at']:
|
|
||||||
if isinstance(usage_row['reset_at'], datetime):
|
|
||||||
reset_at = usage_row['reset_at'].isoformat()
|
|
||||||
else:
|
|
||||||
reset_at = str(usage_row['reset_at'])
|
|
||||||
|
|
||||||
result.append({
|
|
||||||
'feature_id': feature['id'],
|
|
||||||
'name': feature['name'],
|
|
||||||
'description': feature.get('description'),
|
|
||||||
'category': feature.get('category'),
|
|
||||||
'limit_type': feature['limit_type'],
|
|
||||||
'reset_period': feature['reset_period'],
|
|
||||||
'used': access['used'],
|
|
||||||
'limit': access['limit'],
|
|
||||||
'remaining': access['remaining'],
|
|
||||||
'allowed': access['allowed'],
|
|
||||||
'reset_at': reset_at
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
"""
|
|
||||||
Fitness Tests Router - Fitness Test Recording & Norm Tracking
|
|
||||||
|
|
||||||
Endpoints for managing fitness tests:
|
|
||||||
- List fitness tests
|
|
||||||
- Record fitness test results
|
|
||||||
- Calculate norm categories
|
|
||||||
|
|
||||||
Part of v9h Goal System.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/goals", tags=["fitness-tests"])
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Pydantic Models
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class FitnessTestCreate(BaseModel):
|
|
||||||
"""Record fitness test result"""
|
|
||||||
test_type: str
|
|
||||||
result_value: float
|
|
||||||
result_unit: str
|
|
||||||
test_date: date
|
|
||||||
test_conditions: Optional[str] = None
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Endpoints
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/tests")
|
|
||||||
def list_fitness_tests(session: dict = Depends(require_auth)):
|
|
||||||
"""List all fitness tests"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, test_type, result_value, result_unit,
|
|
||||||
test_date, test_conditions, norm_category, created_at
|
|
||||||
FROM fitness_tests
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY test_date DESC
|
|
||||||
""", (pid,))
|
|
||||||
|
|
||||||
return [r2d(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
@router.post("/tests")
|
|
||||||
def create_fitness_test(data: FitnessTestCreate, session: dict = Depends(require_auth)):
|
|
||||||
"""Record fitness test result"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Calculate norm category (simplified for now)
|
|
||||||
norm_category = _calculate_norm_category(
|
|
||||||
data.test_type,
|
|
||||||
data.result_value,
|
|
||||||
data.result_unit
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO fitness_tests (
|
|
||||||
profile_id, test_type, result_value, result_unit,
|
|
||||||
test_date, test_conditions, norm_category
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
pid, data.test_type, data.result_value, data.result_unit,
|
|
||||||
data.test_date, data.test_conditions, norm_category
|
|
||||||
))
|
|
||||||
|
|
||||||
test_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
return {"id": test_id, "norm_category": norm_category}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Helper Functions
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _calculate_norm_category(test_type: str, value: float, unit: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Calculate norm category for fitness test
|
|
||||||
(Simplified - would need age/gender-specific norms)
|
|
||||||
"""
|
|
||||||
# Placeholder - should use proper norm tables
|
|
||||||
return None
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
"""
|
|
||||||
Focus Areas Router
|
|
||||||
Manages dynamic focus area definitions and user preferences
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"])
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Models
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class FocusAreaCreate(BaseModel):
|
|
||||||
"""Create new focus area definition"""
|
|
||||||
key: str
|
|
||||||
name_de: str
|
|
||||||
name_en: Optional[str] = None
|
|
||||||
icon: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: str = 'custom'
|
|
||||||
|
|
||||||
class FocusAreaUpdate(BaseModel):
|
|
||||||
"""Update focus area definition"""
|
|
||||||
name_de: Optional[str] = None
|
|
||||||
name_en: Optional[str] = None
|
|
||||||
icon: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
class UserFocusPreferences(BaseModel):
|
|
||||||
"""User's focus area weightings (dynamic)"""
|
|
||||||
preferences: dict # {focus_area_id: weight_pct}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Focus Area Definitions (Admin)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/definitions")
|
|
||||||
def list_focus_area_definitions(
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
include_inactive: bool = False
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List all available focus area definitions.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
- include_inactive: Include inactive focus areas (default: false)
|
|
||||||
|
|
||||||
Returns focus areas grouped by category.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
query = """
|
|
||||||
SELECT id, key, name_de, name_en, icon, description, category, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM focus_area_definitions
|
|
||||||
WHERE is_active = true OR %s
|
|
||||||
ORDER BY category, name_de
|
|
||||||
"""
|
|
||||||
|
|
||||||
cur.execute(query, (include_inactive,))
|
|
||||||
areas = [r2d(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Group by category
|
|
||||||
grouped = {}
|
|
||||||
for area in areas:
|
|
||||||
cat = area['category'] or 'other'
|
|
||||||
if cat not in grouped:
|
|
||||||
grouped[cat] = []
|
|
||||||
grouped[cat].append(area)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"areas": areas,
|
|
||||||
"grouped": grouped,
|
|
||||||
"total": len(areas)
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/definitions")
|
|
||||||
def create_focus_area_definition(
|
|
||||||
data: FocusAreaCreate,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create new focus area definition (Admin only).
|
|
||||||
|
|
||||||
Note: Requires admin role.
|
|
||||||
"""
|
|
||||||
# Admin check
|
|
||||||
if session.get('role') != 'admin':
|
|
||||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check if key already exists
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM focus_area_definitions WHERE key = %s",
|
|
||||||
(data.key,)
|
|
||||||
)
|
|
||||||
if cur.fetchone():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Focus Area mit Key '{data.key}' existiert bereits"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO focus_area_definitions
|
|
||||||
(key, name_de, name_en, icon, description, category)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
data.key, data.name_de, data.name_en,
|
|
||||||
data.icon, data.description, data.category
|
|
||||||
))
|
|
||||||
|
|
||||||
area_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": area_id,
|
|
||||||
"message": f"Focus Area '{data.name_de}' erstellt"
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.put("/definitions/{area_id}")
|
|
||||||
def update_focus_area_definition(
|
|
||||||
area_id: str,
|
|
||||||
data: FocusAreaUpdate,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""Update focus area definition (Admin only)"""
|
|
||||||
# Admin check
|
|
||||||
if session.get('role') != 'admin':
|
|
||||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Build dynamic UPDATE
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
|
|
||||||
if data.name_de is not None:
|
|
||||||
updates.append("name_de = %s")
|
|
||||||
values.append(data.name_de)
|
|
||||||
if data.name_en is not None:
|
|
||||||
updates.append("name_en = %s")
|
|
||||||
values.append(data.name_en)
|
|
||||||
if data.icon is not None:
|
|
||||||
updates.append("icon = %s")
|
|
||||||
values.append(data.icon)
|
|
||||||
if data.description is not None:
|
|
||||||
updates.append("description = %s")
|
|
||||||
values.append(data.description)
|
|
||||||
if data.category is not None:
|
|
||||||
updates.append("category = %s")
|
|
||||||
values.append(data.category)
|
|
||||||
if data.is_active is not None:
|
|
||||||
updates.append("is_active = %s")
|
|
||||||
values.append(data.is_active)
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
|
||||||
|
|
||||||
updates.append("updated_at = NOW()")
|
|
||||||
values.append(area_id)
|
|
||||||
|
|
||||||
query = f"""
|
|
||||||
UPDATE focus_area_definitions
|
|
||||||
SET {', '.join(updates)}
|
|
||||||
WHERE id = %s
|
|
||||||
RETURNING id
|
|
||||||
"""
|
|
||||||
|
|
||||||
cur.execute(query, values)
|
|
||||||
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
|
||||||
|
|
||||||
return {"message": "Focus Area aktualisiert"}
|
|
||||||
|
|
||||||
@router.delete("/definitions/{area_id}")
|
|
||||||
def delete_focus_area_definition(
|
|
||||||
area_id: str,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete focus area definition (Admin only).
|
|
||||||
|
|
||||||
Cascades: Deletes all goal_focus_contributions referencing this area.
|
|
||||||
"""
|
|
||||||
# Admin check
|
|
||||||
if session.get('role') != 'admin':
|
|
||||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check if area is used
|
|
||||||
cur.execute(
|
|
||||||
"SELECT COUNT(*) as count FROM goal_focus_contributions WHERE focus_area_id = %s",
|
|
||||||
(area_id,)
|
|
||||||
)
|
|
||||||
count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
if count > 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Focus Area wird von {count} Ziel(en) verwendet. "
|
|
||||||
"Bitte erst Zuordnungen entfernen oder auf 'inaktiv' setzen."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM focus_area_definitions WHERE id = %s RETURNING id",
|
|
||||||
(area_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
|
||||||
|
|
||||||
return {"message": "Focus Area gelöscht"}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# User Focus Preferences
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/user-preferences")
|
|
||||||
def get_user_focus_preferences(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Get user's focus area weightings (dynamic system).
|
|
||||||
|
|
||||||
Returns focus areas with user-set weights, grouped by category.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get dynamic preferences (Migration 032)
|
|
||||||
try:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
fa.id, fa.key, fa.name_de, fa.name_en, fa.icon,
|
|
||||||
fa.category, fa.description,
|
|
||||||
ufw.weight
|
|
||||||
FROM user_focus_area_weights ufw
|
|
||||||
JOIN focus_area_definitions fa ON ufw.focus_area_id = fa.id
|
|
||||||
WHERE ufw.profile_id = %s AND ufw.weight > 0
|
|
||||||
ORDER BY fa.category, fa.name_de
|
|
||||||
""", (pid,))
|
|
||||||
|
|
||||||
weights = [r2d(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Calculate percentages from weights
|
|
||||||
total_weight = sum(w['weight'] for w in weights)
|
|
||||||
if total_weight > 0:
|
|
||||||
for w in weights:
|
|
||||||
w['percentage'] = round((w['weight'] / total_weight) * 100)
|
|
||||||
else:
|
|
||||||
for w in weights:
|
|
||||||
w['percentage'] = 0
|
|
||||||
|
|
||||||
# Group by category
|
|
||||||
grouped = {}
|
|
||||||
for w in weights:
|
|
||||||
cat = w['category'] or 'other'
|
|
||||||
if cat not in grouped:
|
|
||||||
grouped[cat] = []
|
|
||||||
grouped[cat].append(w)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"weights": weights,
|
|
||||||
"grouped": grouped,
|
|
||||||
"total_weight": total_weight
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Migration 032 not applied yet - return empty
|
|
||||||
print(f"[WARNING] user_focus_area_weights not found: {e}")
|
|
||||||
return {
|
|
||||||
"weights": [],
|
|
||||||
"grouped": {},
|
|
||||||
"total_weight": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.put("/user-preferences")
|
|
||||||
def update_user_focus_preferences(
|
|
||||||
data: dict,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update user's focus area weightings (dynamic system).
|
|
||||||
|
|
||||||
Expects: { "weights": { "focus_area_id": weight, ... } }
|
|
||||||
Weights are relative (0-100), normalized in display only.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
if 'weights' not in data:
|
|
||||||
raise HTTPException(status_code=400, detail="'weights' field required")
|
|
||||||
|
|
||||||
weights = data['weights'] # Dict: focus_area_id → weight
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Delete existing weights
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM user_focus_area_weights WHERE profile_id = %s",
|
|
||||||
(pid,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert new weights (only non-zero)
|
|
||||||
for focus_area_id, weight in weights.items():
|
|
||||||
weight_int = int(weight)
|
|
||||||
if weight_int > 0:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO user_focus_area_weights
|
|
||||||
(profile_id, focus_area_id, weight)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
ON CONFLICT (profile_id, focus_area_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
weight = EXCLUDED.weight,
|
|
||||||
updated_at = NOW()
|
|
||||||
""", (pid, focus_area_id, weight_int))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Focus Area Gewichtungen aktualisiert",
|
|
||||||
"count": len([w for w in weights.values() if int(w) > 0])
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Stats & Analytics
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def get_focus_area_stats(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Get focus area statistics for current user.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- Progress per focus area (avg of all contributing goals)
|
|
||||||
- Goal count per focus area
|
|
||||||
- Top/bottom performing areas
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
fa.id, fa.key, fa.name_de, fa.icon, fa.category,
|
|
||||||
COUNT(DISTINCT gfc.goal_id) as goal_count,
|
|
||||||
AVG(g.progress_pct) as avg_progress,
|
|
||||||
SUM(gfc.contribution_weight) as total_contribution
|
|
||||||
FROM focus_area_definitions fa
|
|
||||||
LEFT JOIN goal_focus_contributions gfc ON fa.id = gfc.focus_area_id
|
|
||||||
LEFT JOIN goals g ON gfc.goal_id = g.id AND g.profile_id = %s
|
|
||||||
WHERE fa.is_active = true
|
|
||||||
GROUP BY fa.id
|
|
||||||
HAVING COUNT(DISTINCT gfc.goal_id) > 0 -- Only areas with goals
|
|
||||||
ORDER BY avg_progress DESC NULLS LAST
|
|
||||||
""", (pid,))
|
|
||||||
|
|
||||||
stats = [r2d(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"stats": stats,
|
|
||||||
"top_area": stats[0] if stats else None,
|
|
||||||
"bottom_area": stats[-1] if len(stats) > 1 else None
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
"""
|
|
||||||
Goal Progress Router - Progress Tracking for Goals
|
|
||||||
|
|
||||||
Endpoints for logging and managing goal progress:
|
|
||||||
- Get progress history
|
|
||||||
- Create manual progress entries
|
|
||||||
- Delete progress entries
|
|
||||||
|
|
||||||
Part of v9h Goal System.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/goals", tags=["goal-progress"])
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Pydantic Models
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class GoalProgressCreate(BaseModel):
|
|
||||||
"""Log progress for a goal"""
|
|
||||||
date: date
|
|
||||||
value: float
|
|
||||||
note: Optional[str] = None
|
|
||||||
|
|
||||||
class GoalProgressUpdate(BaseModel):
|
|
||||||
"""Update progress entry"""
|
|
||||||
value: Optional[float] = None
|
|
||||||
note: Optional[str] = None
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Endpoints
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/{goal_id}/progress")
|
|
||||||
def get_goal_progress(goal_id: str, session: dict = Depends(require_auth)):
|
|
||||||
"""Get progress history for a goal"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Verify ownership
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM goals WHERE id = %s AND profile_id = %s",
|
|
||||||
(goal_id, pid)
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
|
|
||||||
|
|
||||||
# Get progress entries
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, date, value, note, source, created_at
|
|
||||||
FROM goal_progress_log
|
|
||||||
WHERE goal_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
""", (goal_id,))
|
|
||||||
|
|
||||||
entries = cur.fetchall()
|
|
||||||
return [r2d(e) for e in entries]
|
|
||||||
|
|
||||||
@router.post("/{goal_id}/progress")
|
|
||||||
def create_goal_progress(goal_id: str, data: GoalProgressCreate, session: dict = Depends(require_auth)):
|
|
||||||
"""Log new progress for a goal"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Verify ownership and check if manual entry is allowed
|
|
||||||
cur.execute("""
|
|
||||||
SELECT g.id, g.unit, gt.source_table
|
|
||||||
FROM goals g
|
|
||||||
LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key
|
|
||||||
WHERE g.id = %s AND g.profile_id = %s
|
|
||||||
""", (goal_id, pid))
|
|
||||||
goal = cur.fetchone()
|
|
||||||
if not goal:
|
|
||||||
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
|
|
||||||
|
|
||||||
# Prevent manual entries for goals with automatic data sources
|
|
||||||
if goal['source_table']:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Manuelle Einträge nicht erlaubt für automatisch erfasste Ziele. "
|
|
||||||
f"Bitte nutze die entsprechende Erfassungsseite (z.B. Gewicht, Aktivität)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert progress entry
|
|
||||||
try:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO goal_progress_log (goal_id, profile_id, date, value, note, source)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, 'manual')
|
|
||||||
RETURNING id
|
|
||||||
""", (goal_id, pid, data.date, data.value, data.note))
|
|
||||||
|
|
||||||
progress_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
# Trigger will auto-update goals.current_value
|
|
||||||
return {
|
|
||||||
"id": progress_id,
|
|
||||||
"message": f"Fortschritt erfasst: {data.value} {goal['unit']}"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
if "unique_progress_per_day" in str(e):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Für {data.date} existiert bereits ein Eintrag. Bitte bearbeite den existierenden Eintrag."
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {str(e)}")
|
|
||||||
|
|
||||||
@router.delete("/{goal_id}/progress/{progress_id}")
|
|
||||||
def delete_goal_progress(goal_id: str, progress_id: str, session: dict = Depends(require_auth)):
|
|
||||||
"""Delete progress entry"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Verify ownership
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM goals WHERE id = %s AND profile_id = %s",
|
|
||||||
(goal_id, pid)
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
|
|
||||||
|
|
||||||
# Delete progress entry
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM goal_progress_log WHERE id = %s AND goal_id = %s AND profile_id = %s",
|
|
||||||
(progress_id, goal_id, pid)
|
|
||||||
)
|
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
raise HTTPException(status_code=404, detail="Progress-Eintrag nicht gefunden")
|
|
||||||
|
|
||||||
# After deletion, recalculate current_value from remaining entries
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE goals
|
|
||||||
SET current_value = (
|
|
||||||
SELECT value FROM goal_progress_log
|
|
||||||
WHERE goal_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
WHERE id = %s
|
|
||||||
""", (goal_id, goal_id))
|
|
||||||
|
|
||||||
return {"message": "Progress-Eintrag gelöscht"}
|
|
||||||
|
|
@ -1,426 +0,0 @@
|
||||||
"""
|
|
||||||
Goal Types Router - Custom Goal Type Definitions
|
|
||||||
|
|
||||||
Endpoints for managing goal type definitions (admin-only):
|
|
||||||
- CRUD for goal type definitions
|
|
||||||
- Schema info for building custom types
|
|
||||||
|
|
||||||
Part of v9h Goal System.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/goals", tags=["goal-types"])
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Pydantic Models
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class GoalTypeCreate(BaseModel):
|
|
||||||
"""Create custom goal type definition"""
|
|
||||||
type_key: str
|
|
||||||
label_de: str
|
|
||||||
label_en: Optional[str] = None
|
|
||||||
unit: str
|
|
||||||
icon: Optional[str] = None
|
|
||||||
category: Optional[str] = 'custom'
|
|
||||||
source_table: Optional[str] = None
|
|
||||||
source_column: Optional[str] = None
|
|
||||||
aggregation_method: Optional[str] = 'latest'
|
|
||||||
calculation_formula: Optional[str] = None
|
|
||||||
filter_conditions: Optional[dict] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
class GoalTypeUpdate(BaseModel):
|
|
||||||
"""Update goal type definition"""
|
|
||||||
label_de: Optional[str] = None
|
|
||||||
label_en: Optional[str] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
icon: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
source_table: Optional[str] = None
|
|
||||||
source_column: Optional[str] = None
|
|
||||||
aggregation_method: Optional[str] = None
|
|
||||||
calculation_formula: Optional[str] = None
|
|
||||||
filter_conditions: Optional[dict] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Endpoints
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/schema-info")
|
|
||||||
def get_schema_info(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Get available tables and columns for goal type creation.
|
|
||||||
|
|
||||||
Admin-only endpoint for building custom goal types.
|
|
||||||
Returns structure with descriptions for UX guidance.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
# Check admin role
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,))
|
|
||||||
profile = cur.fetchone()
|
|
||||||
|
|
||||||
if not profile or profile['role'] != 'admin':
|
|
||||||
raise HTTPException(status_code=403, detail="Admin-Zugriff erforderlich")
|
|
||||||
|
|
||||||
# Define relevant tables with descriptions
|
|
||||||
# Only include tables that make sense for goal tracking
|
|
||||||
schema = {
|
|
||||||
"weight_log": {
|
|
||||||
"description": "Gewichtsverlauf",
|
|
||||||
"columns": {
|
|
||||||
"weight": {"type": "DECIMAL", "description": "Körpergewicht in kg"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"caliper_log": {
|
|
||||||
"description": "Caliper-Messungen (Hautfalten)",
|
|
||||||
"columns": {
|
|
||||||
"body_fat_pct": {"type": "DECIMAL", "description": "Körperfettanteil in %"},
|
|
||||||
"sum_mm": {"type": "DECIMAL", "description": "Summe Hautfalten in mm"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"circumference_log": {
|
|
||||||
"description": "Umfangsmessungen",
|
|
||||||
"columns": {
|
|
||||||
"c_neck": {"type": "DECIMAL", "description": "Nackenumfang in cm"},
|
|
||||||
"c_chest": {"type": "DECIMAL", "description": "Brustumfang in cm"},
|
|
||||||
"c_waist": {"type": "DECIMAL", "description": "Taillenumfang in cm"},
|
|
||||||
"c_hips": {"type": "DECIMAL", "description": "Hüftumfang in cm"},
|
|
||||||
"c_thigh_l": {"type": "DECIMAL", "description": "Oberschenkel links in cm"},
|
|
||||||
"c_thigh_r": {"type": "DECIMAL", "description": "Oberschenkel rechts in cm"},
|
|
||||||
"c_calf_l": {"type": "DECIMAL", "description": "Wade links in cm"},
|
|
||||||
"c_calf_r": {"type": "DECIMAL", "description": "Wade rechts in cm"},
|
|
||||||
"c_bicep_l": {"type": "DECIMAL", "description": "Bizeps links in cm"},
|
|
||||||
"c_bicep_r": {"type": "DECIMAL", "description": "Bizeps rechts in cm"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"activity_log": {
|
|
||||||
"description": "Trainingseinheiten",
|
|
||||||
"columns": {
|
|
||||||
"id": {"type": "UUID", "description": "ID (für Zählung von Einheiten)"},
|
|
||||||
"duration_minutes": {"type": "INTEGER", "description": "Trainingsdauer in Minuten"},
|
|
||||||
"perceived_exertion": {"type": "INTEGER", "description": "Belastungsempfinden (1-10)"},
|
|
||||||
"quality_rating": {"type": "INTEGER", "description": "Qualitätsbewertung (1-10)"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nutrition_log": {
|
|
||||||
"description": "Ernährungstagebuch",
|
|
||||||
"columns": {
|
|
||||||
"calories": {"type": "INTEGER", "description": "Kalorien in kcal"},
|
|
||||||
"protein_g": {"type": "DECIMAL", "description": "Protein in g"},
|
|
||||||
"carbs_g": {"type": "DECIMAL", "description": "Kohlenhydrate in g"},
|
|
||||||
"fat_g": {"type": "DECIMAL", "description": "Fett in g"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sleep_log": {
|
|
||||||
"description": "Schlafprotokoll",
|
|
||||||
"columns": {
|
|
||||||
"total_minutes": {"type": "INTEGER", "description": "Gesamtschlafdauer in Minuten"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vitals_baseline": {
|
|
||||||
"description": "Vitalwerte (morgens)",
|
|
||||||
"columns": {
|
|
||||||
"resting_hr": {"type": "INTEGER", "description": "Ruhepuls in bpm"},
|
|
||||||
"hrv_rmssd": {"type": "INTEGER", "description": "Herzratenvariabilität (RMSSD) in ms"},
|
|
||||||
"vo2_max": {"type": "DECIMAL", "description": "VO2 Max in ml/kg/min"},
|
|
||||||
"spo2": {"type": "INTEGER", "description": "Sauerstoffsättigung in %"},
|
|
||||||
"respiratory_rate": {"type": "INTEGER", "description": "Atemfrequenz pro Minute"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"blood_pressure_log": {
|
|
||||||
"description": "Blutdruckmessungen",
|
|
||||||
"columns": {
|
|
||||||
"systolic": {"type": "INTEGER", "description": "Systolischer Blutdruck in mmHg"},
|
|
||||||
"diastolic": {"type": "INTEGER", "description": "Diastolischer Blutdruck in mmHg"},
|
|
||||||
"pulse": {"type": "INTEGER", "description": "Puls in bpm"}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rest_days": {
|
|
||||||
"description": "Ruhetage",
|
|
||||||
"columns": {
|
|
||||||
"id": {"type": "UUID", "description": "ID (für Zählung von Ruhetagen)"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return schema
|
|
||||||
|
|
||||||
@router.get("/goal-types")
|
|
||||||
def list_goal_type_definitions(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Get all active goal type definitions.
|
|
||||||
|
|
||||||
Public endpoint - returns all available goal types for dropdown.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
calculation_formula, filter_conditions, description, is_system, is_active,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM goal_type_definitions
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY
|
|
||||||
CASE
|
|
||||||
WHEN is_system = true THEN 0
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
label_de
|
|
||||||
""")
|
|
||||||
|
|
||||||
results = [r2d(row) for row in cur.fetchall()]
|
|
||||||
print(f"[DEBUG] Loaded {len(results)} goal types")
|
|
||||||
return results
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] list_goal_type_definitions failed: {e}")
|
|
||||||
print(traceback.format_exc())
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Fehler beim Laden der Goal Types: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/goal-types")
|
|
||||||
def create_goal_type_definition(
|
|
||||||
data: GoalTypeCreate,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create custom goal type definition.
|
|
||||||
|
|
||||||
Admin-only endpoint for creating new goal types.
|
|
||||||
Users with admin role can define custom metrics.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
# Check admin role
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,))
|
|
||||||
profile = cur.fetchone()
|
|
||||||
|
|
||||||
if not profile or profile['role'] != 'admin':
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Admin-Zugriff erforderlich"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate type_key is unique
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM goal_type_definitions WHERE type_key = %s",
|
|
||||||
(data.type_key,)
|
|
||||||
)
|
|
||||||
if cur.fetchone():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Goal Type '{data.type_key}' existiert bereits"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert new goal type
|
|
||||||
import json as json_lib
|
|
||||||
filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO goal_type_definitions (
|
|
||||||
type_key, label_de, label_en, unit, icon, category,
|
|
||||||
source_table, source_column, aggregation_method,
|
|
||||||
calculation_formula, filter_conditions, description, is_active, is_system
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
data.type_key, data.label_de, data.label_en, data.unit, data.icon,
|
|
||||||
data.category, data.source_table, data.source_column,
|
|
||||||
data.aggregation_method, data.calculation_formula, filter_json, data.description,
|
|
||||||
True, False # is_active=True, is_system=False
|
|
||||||
))
|
|
||||||
|
|
||||||
goal_type_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": goal_type_id,
|
|
||||||
"message": f"Goal Type '{data.label_de}' erstellt"
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.put("/goal-types/{goal_type_id}")
|
|
||||||
def update_goal_type_definition(
|
|
||||||
goal_type_id: str,
|
|
||||||
data: GoalTypeUpdate,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update goal type definition.
|
|
||||||
|
|
||||||
Admin-only. System goal types can be updated but not deleted.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check admin role
|
|
||||||
cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,))
|
|
||||||
profile = cur.fetchone()
|
|
||||||
|
|
||||||
if not profile or profile['role'] != 'admin':
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Admin-Zugriff erforderlich"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check goal type exists
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM goal_type_definitions WHERE id = %s",
|
|
||||||
(goal_type_id,)
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Goal Type nicht gefunden")
|
|
||||||
|
|
||||||
# Build update query
|
|
||||||
updates = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if data.label_de is not None:
|
|
||||||
updates.append("label_de = %s")
|
|
||||||
params.append(data.label_de)
|
|
||||||
|
|
||||||
if data.label_en is not None:
|
|
||||||
updates.append("label_en = %s")
|
|
||||||
params.append(data.label_en)
|
|
||||||
|
|
||||||
if data.unit is not None:
|
|
||||||
updates.append("unit = %s")
|
|
||||||
params.append(data.unit)
|
|
||||||
|
|
||||||
if data.icon is not None:
|
|
||||||
updates.append("icon = %s")
|
|
||||||
params.append(data.icon)
|
|
||||||
|
|
||||||
if data.category is not None:
|
|
||||||
updates.append("category = %s")
|
|
||||||
params.append(data.category)
|
|
||||||
|
|
||||||
if data.source_table is not None:
|
|
||||||
updates.append("source_table = %s")
|
|
||||||
params.append(data.source_table)
|
|
||||||
|
|
||||||
if data.source_column is not None:
|
|
||||||
updates.append("source_column = %s")
|
|
||||||
params.append(data.source_column)
|
|
||||||
|
|
||||||
if data.aggregation_method is not None:
|
|
||||||
updates.append("aggregation_method = %s")
|
|
||||||
params.append(data.aggregation_method)
|
|
||||||
|
|
||||||
if data.calculation_formula is not None:
|
|
||||||
updates.append("calculation_formula = %s")
|
|
||||||
params.append(data.calculation_formula)
|
|
||||||
|
|
||||||
if data.filter_conditions is not None:
|
|
||||||
import json as json_lib
|
|
||||||
filter_json = json_lib.dumps(data.filter_conditions) if data.filter_conditions else None
|
|
||||||
updates.append("filter_conditions = %s")
|
|
||||||
params.append(filter_json)
|
|
||||||
|
|
||||||
if data.description is not None:
|
|
||||||
updates.append("description = %s")
|
|
||||||
params.append(data.description)
|
|
||||||
|
|
||||||
if data.is_active is not None:
|
|
||||||
updates.append("is_active = %s")
|
|
||||||
params.append(data.is_active)
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
|
||||||
|
|
||||||
updates.append("updated_at = NOW()")
|
|
||||||
params.append(goal_type_id)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE goal_type_definitions SET {', '.join(updates)} WHERE id = %s",
|
|
||||||
tuple(params)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"message": "Goal Type aktualisiert"}
|
|
||||||
|
|
||||||
@router.delete("/goal-types/{goal_type_id}")
|
|
||||||
def delete_goal_type_definition(
|
|
||||||
goal_type_id: str,
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete (deactivate) goal type definition.
|
|
||||||
|
|
||||||
Admin-only. System goal types cannot be deleted, only deactivated.
|
|
||||||
Custom goal types can be fully deleted if no goals reference them.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Check admin role
|
|
||||||
cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,))
|
|
||||||
profile = cur.fetchone()
|
|
||||||
|
|
||||||
if not profile or profile['role'] != 'admin':
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Admin-Zugriff erforderlich"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get goal type info
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id, type_key, is_system FROM goal_type_definitions WHERE id = %s",
|
|
||||||
(goal_type_id,)
|
|
||||||
)
|
|
||||||
goal_type = cur.fetchone()
|
|
||||||
|
|
||||||
if not goal_type:
|
|
||||||
raise HTTPException(status_code=404, detail="Goal Type nicht gefunden")
|
|
||||||
|
|
||||||
# Check if any goals use this type
|
|
||||||
cur.execute(
|
|
||||||
"SELECT COUNT(*) as count FROM goals WHERE goal_type = %s",
|
|
||||||
(goal_type['type_key'],)
|
|
||||||
)
|
|
||||||
count = cur.fetchone()['count']
|
|
||||||
|
|
||||||
if count > 0:
|
|
||||||
# Deactivate instead of delete
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE goal_type_definitions SET is_active = false WHERE id = %s",
|
|
||||||
(goal_type_id,)
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"message": f"Goal Type deaktiviert ({count} Ziele nutzen diesen Typ)"
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
if goal_type['is_system']:
|
|
||||||
# System types: only deactivate
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE goal_type_definitions SET is_active = false WHERE id = %s",
|
|
||||||
(goal_type_id,)
|
|
||||||
)
|
|
||||||
return {"message": "System Goal Type deaktiviert"}
|
|
||||||
else:
|
|
||||||
# Custom types: delete
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM goal_type_definitions WHERE id = %s",
|
|
||||||
(goal_type_id,)
|
|
||||||
)
|
|
||||||
return {"message": "Goal Type gelöscht"}
|
|
||||||
|
|
@ -1,838 +0,0 @@
|
||||||
"""
|
|
||||||
Goals Router - Core Goal CRUD & Focus Areas (Streamlined v2.0)
|
|
||||||
|
|
||||||
Endpoints for managing:
|
|
||||||
- Strategic focus areas (weighted multi-goal system)
|
|
||||||
- Tactical goal targets (concrete values with deadlines)
|
|
||||||
- Grouped goal views
|
|
||||||
|
|
||||||
Part of v9h Goal System (Phase 0a).
|
|
||||||
|
|
||||||
NOTE: Code split complete! Related endpoints moved to:
|
|
||||||
- goal_types.py → Goal Type Definitions (Admin CRUD)
|
|
||||||
- goal_progress.py → Progress tracking
|
|
||||||
- training_phases.py → Training phase management
|
|
||||||
- fitness_tests.py → Fitness test recording
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth
|
|
||||||
from goal_utils import get_current_value_for_goal
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/goals", tags=["goals"])
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_dates(obj):
|
|
||||||
"""Convert date/datetime objects to ISO format strings for JSON serialization."""
|
|
||||||
if obj is None:
|
|
||||||
return None
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: serialize_dates(v) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return [serialize_dates(item) for item in obj]
|
|
||||||
if isinstance(obj, (date,)):
|
|
||||||
return obj.isoformat()
|
|
||||||
return obj
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Pydantic Models
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class GoalModeUpdate(BaseModel):
|
|
||||||
"""Update strategic goal mode (deprecated - use FocusAreasUpdate)"""
|
|
||||||
goal_mode: str # weight_loss, strength, endurance, recomposition, health
|
|
||||||
|
|
||||||
class FocusAreasUpdate(BaseModel):
|
|
||||||
"""Update focus area weights (v2.0)"""
|
|
||||||
weight_loss_pct: int
|
|
||||||
muscle_gain_pct: int
|
|
||||||
strength_pct: int
|
|
||||||
endurance_pct: int
|
|
||||||
flexibility_pct: int
|
|
||||||
health_pct: int
|
|
||||||
|
|
||||||
class FocusContribution(BaseModel):
|
|
||||||
"""Focus area contribution (v2.0)"""
|
|
||||||
focus_area_id: str
|
|
||||||
contribution_weight: float = 100.0 # 0-100%
|
|
||||||
|
|
||||||
class GoalCreate(BaseModel):
|
|
||||||
"""Create or update a concrete goal"""
|
|
||||||
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
|
|
||||||
is_primary: bool = False # Kept for backward compatibility
|
|
||||||
target_value: float
|
|
||||||
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
|
|
||||||
target_date: Optional[date] = None
|
|
||||||
start_date: Optional[date] = None # When goal started (defaults to today, can be historical)
|
|
||||||
start_value: Optional[float] = None # Auto-populated from start_date if not provided
|
|
||||||
category: Optional[str] = 'other' # body, training, nutrition, recovery, health, other
|
|
||||||
priority: Optional[int] = 2 # 1=high, 2=medium, 3=low
|
|
||||||
name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
focus_contributions: Optional[List[FocusContribution]] = [] # v2.0: Many-to-Many
|
|
||||||
|
|
||||||
class GoalUpdate(BaseModel):
|
|
||||||
"""Update existing goal"""
|
|
||||||
target_value: Optional[float] = None
|
|
||||||
target_date: Optional[date] = None
|
|
||||||
start_date: Optional[date] = None # Change start date (recalculates start_value)
|
|
||||||
start_value: Optional[float] = None # Manually override start value
|
|
||||||
status: Optional[str] = None # active, reached, abandoned, expired
|
|
||||||
is_primary: Optional[bool] = None # Kept for backward compatibility
|
|
||||||
category: Optional[str] = None # body, training, nutrition, recovery, health, other
|
|
||||||
priority: Optional[int] = None # 1=high, 2=medium, 3=low
|
|
||||||
name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
focus_contributions: Optional[List[FocusContribution]] = None # v2.0: Many-to-Many
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Strategic Layer: Goal Modes
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/mode")
|
|
||||||
def get_goal_mode(session: dict = Depends(require_auth)):
|
|
||||||
"""Get user's current strategic goal mode"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT goal_mode FROM profiles WHERE id = %s",
|
|
||||||
(pid,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"goal_mode": row['goal_mode'] or 'health',
|
|
||||||
"description": _get_goal_mode_description(row['goal_mode'] or 'health')
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.put("/mode")
|
|
||||||
def update_goal_mode(data: GoalModeUpdate, session: dict = Depends(require_auth)):
|
|
||||||
"""Update user's strategic goal mode"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
# Validate goal mode
|
|
||||||
valid_modes = ['weight_loss', 'strength', 'endurance', 'recomposition', 'health']
|
|
||||||
if data.goal_mode not in valid_modes:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Ungültiger Goal Mode. Erlaubt: {', '.join(valid_modes)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE profiles SET goal_mode = %s WHERE id = %s",
|
|
||||||
(data.goal_mode, pid)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"goal_mode": data.goal_mode,
|
|
||||||
"description": _get_goal_mode_description(data.goal_mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_goal_mode_description(mode: str) -> str:
|
|
||||||
"""Get description for goal mode"""
|
|
||||||
descriptions = {
|
|
||||||
'weight_loss': 'Gewichtsreduktion (Kaloriendefizit, Fettabbau)',
|
|
||||||
'strength': 'Kraftaufbau (Muskelwachstum, progressive Belastung)',
|
|
||||||
'endurance': 'Ausdauer (VO2Max, aerobe Kapazität)',
|
|
||||||
'recomposition': 'Körperkomposition (gleichzeitig Fett ab- und Muskeln aufbauen)',
|
|
||||||
'health': 'Allgemeine Gesundheit (ausgewogen, präventiv)'
|
|
||||||
}
|
|
||||||
return descriptions.get(mode, 'Unbekannt')
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Focus Areas (v2.0): Weighted Multi-Goal System
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/focus-areas")
|
|
||||||
def get_focus_areas(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Get current focus area weights.
|
|
||||||
|
|
||||||
Returns custom weights if set, otherwise derives from goal_mode.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Try to get custom focus areas (user_focus_preferences after Migration 031)
|
|
||||||
try:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
|
|
||||||
endurance_pct, flexibility_pct, health_pct,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM user_focus_preferences
|
|
||||||
WHERE profile_id = %s
|
|
||||||
LIMIT 1
|
|
||||||
""", (pid,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
except Exception as e:
|
|
||||||
# Migration 031 not applied yet, try old table name
|
|
||||||
print(f"[WARNING] user_focus_preferences not found, trying old focus_areas: {e}")
|
|
||||||
try:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT weight_loss_pct, muscle_gain_pct, strength_pct,
|
|
||||||
endurance_pct, flexibility_pct, health_pct,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM focus_areas
|
|
||||||
WHERE profile_id = %s AND active = true
|
|
||||||
LIMIT 1
|
|
||||||
""", (pid,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
except:
|
|
||||||
row = None
|
|
||||||
|
|
||||||
if row:
|
|
||||||
return {
|
|
||||||
"custom": True,
|
|
||||||
"weight_loss_pct": row['weight_loss_pct'],
|
|
||||||
"muscle_gain_pct": row['muscle_gain_pct'],
|
|
||||||
"strength_pct": row['strength_pct'],
|
|
||||||
"endurance_pct": row['endurance_pct'],
|
|
||||||
"flexibility_pct": row['flexibility_pct'],
|
|
||||||
"health_pct": row['health_pct'],
|
|
||||||
"updated_at": row['updated_at']
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fallback: Derive from goal_mode
|
|
||||||
cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (pid,))
|
|
||||||
profile = cur.fetchone()
|
|
||||||
|
|
||||||
if not profile or not profile['goal_mode']:
|
|
||||||
# Default balanced health
|
|
||||||
return {
|
|
||||||
"custom": False,
|
|
||||||
"weight_loss_pct": 0,
|
|
||||||
"muscle_gain_pct": 0,
|
|
||||||
"strength_pct": 10,
|
|
||||||
"endurance_pct": 20,
|
|
||||||
"flexibility_pct": 15,
|
|
||||||
"health_pct": 55,
|
|
||||||
"source": "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Derive from goal_mode (using same logic as migration)
|
|
||||||
mode = profile['goal_mode']
|
|
||||||
mode_mappings = {
|
|
||||||
'weight_loss': {
|
|
||||||
'weight_loss_pct': 60,
|
|
||||||
'muscle_gain_pct': 0,
|
|
||||||
'strength_pct': 10,
|
|
||||||
'endurance_pct': 20,
|
|
||||||
'flexibility_pct': 5,
|
|
||||||
'health_pct': 5
|
|
||||||
},
|
|
||||||
'strength': {
|
|
||||||
'weight_loss_pct': 0,
|
|
||||||
'muscle_gain_pct': 40,
|
|
||||||
'strength_pct': 50,
|
|
||||||
'endurance_pct': 10,
|
|
||||||
'flexibility_pct': 0,
|
|
||||||
'health_pct': 0
|
|
||||||
},
|
|
||||||
'endurance': {
|
|
||||||
'weight_loss_pct': 0,
|
|
||||||
'muscle_gain_pct': 0,
|
|
||||||
'strength_pct': 0,
|
|
||||||
'endurance_pct': 70,
|
|
||||||
'flexibility_pct': 10,
|
|
||||||
'health_pct': 20
|
|
||||||
},
|
|
||||||
'recomposition': {
|
|
||||||
'weight_loss_pct': 30,
|
|
||||||
'muscle_gain_pct': 30,
|
|
||||||
'strength_pct': 25,
|
|
||||||
'endurance_pct': 10,
|
|
||||||
'flexibility_pct': 5,
|
|
||||||
'health_pct': 0
|
|
||||||
},
|
|
||||||
'health': {
|
|
||||||
'weight_loss_pct': 0,
|
|
||||||
'muscle_gain_pct': 0,
|
|
||||||
'strength_pct': 10,
|
|
||||||
'endurance_pct': 20,
|
|
||||||
'flexibility_pct': 15,
|
|
||||||
'health_pct': 55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mapping = mode_mappings.get(mode, mode_mappings['health'])
|
|
||||||
mapping['custom'] = False
|
|
||||||
mapping['source'] = f"goal_mode:{mode}"
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
@router.put("/focus-areas")
|
|
||||||
def update_focus_areas(data: FocusAreasUpdate, session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Update focus area weights (upsert).
|
|
||||||
|
|
||||||
Validates that sum = 100 and all values are 0-100.
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
# Validate sum = 100
|
|
||||||
total = (
|
|
||||||
data.weight_loss_pct + data.muscle_gain_pct + data.strength_pct +
|
|
||||||
data.endurance_pct + data.flexibility_pct + data.health_pct
|
|
||||||
)
|
|
||||||
|
|
||||||
if total != 100:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Summe muss 100% sein (aktuell: {total}%)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate range 0-100
|
|
||||||
values = [
|
|
||||||
data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct,
|
|
||||||
data.endurance_pct, data.flexibility_pct, data.health_pct
|
|
||||||
]
|
|
||||||
|
|
||||||
if any(v < 0 or v > 100 for v in values):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Alle Werte müssen zwischen 0 und 100 liegen"
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Deactivate old focus_areas
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE focus_areas SET active = false WHERE profile_id = %s",
|
|
||||||
(pid,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert new focus_areas
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO focus_areas (
|
|
||||||
profile_id, weight_loss_pct, muscle_gain_pct, strength_pct,
|
|
||||||
endurance_pct, flexibility_pct, health_pct
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
pid, data.weight_loss_pct, data.muscle_gain_pct, data.strength_pct,
|
|
||||||
data.endurance_pct, data.flexibility_pct, data.health_pct
|
|
||||||
))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Fokus-Bereiche aktualisiert",
|
|
||||||
"weight_loss_pct": data.weight_loss_pct,
|
|
||||||
"muscle_gain_pct": data.muscle_gain_pct,
|
|
||||||
"strength_pct": data.strength_pct,
|
|
||||||
"endurance_pct": data.endurance_pct,
|
|
||||||
"flexibility_pct": data.flexibility_pct,
|
|
||||||
"health_pct": data.health_pct
|
|
||||||
}
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Tactical Layer: Concrete Goals - Core CRUD
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.get("/list")
|
|
||||||
def list_goals(session: dict = Depends(require_auth)):
|
|
||||||
"""List all goals for current user"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
try:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, goal_type, is_primary, status,
|
|
||||||
target_value, current_value, start_value, unit,
|
|
||||||
start_date, target_date, reached_date,
|
|
||||||
name, description,
|
|
||||||
progress_pct, projection_date, on_track,
|
|
||||||
created_at, updated_at
|
|
||||||
FROM goals
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY is_primary DESC, created_at DESC
|
|
||||||
""", (pid,))
|
|
||||||
|
|
||||||
goals = [r2d(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
# Debug: Show first goal with dates
|
|
||||||
if goals:
|
|
||||||
first = goals[0]
|
|
||||||
|
|
||||||
# Update current values for each goal
|
|
||||||
for goal in goals:
|
|
||||||
try:
|
|
||||||
_update_goal_progress(conn, pid, goal)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] Failed to update progress for goal {goal.get('id')}: {e}")
|
|
||||||
# Continue with other goals even if one fails
|
|
||||||
|
|
||||||
# Serialize date objects to ISO format strings
|
|
||||||
goals = serialize_dates(goals)
|
|
||||||
|
|
||||||
return goals
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] list_goals failed: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Fehler beim Laden der Ziele: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/create")
|
|
||||||
def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
|
|
||||||
"""Create new goal"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# If this is set as primary, unset other primary goals
|
|
||||||
if data.is_primary:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE goals SET is_primary = false WHERE profile_id = %s",
|
|
||||||
(pid,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get current value for this goal type
|
|
||||||
current_value = _get_current_value_for_goal_type(conn, pid, data.goal_type)
|
|
||||||
|
|
||||||
# Determine start_date (default to today if not provided)
|
|
||||||
start_date = data.start_date if data.start_date else date.today()
|
|
||||||
|
|
||||||
# Determine start_value
|
|
||||||
if data.start_value is not None:
|
|
||||||
# User explicitly provided start_value
|
|
||||||
start_value = data.start_value
|
|
||||||
elif start_date < date.today():
|
|
||||||
# Historical start date - try to get historical value
|
|
||||||
historical_data = _get_historical_value_for_goal_type(conn, pid, data.goal_type, start_date)
|
|
||||||
if historical_data is not None:
|
|
||||||
# Use the actual measurement date and value
|
|
||||||
start_date = historical_data['date']
|
|
||||||
start_value = historical_data['value']
|
|
||||||
print(f"[INFO] Auto-adjusted start_date to {start_date} (first measurement)")
|
|
||||||
else:
|
|
||||||
# No data found, fall back to current value and keep original date
|
|
||||||
start_value = current_value
|
|
||||||
print(f"[WARN] No historical data for {data.goal_type} on or after {start_date}, using current value")
|
|
||||||
else:
|
|
||||||
# Start date is today, use current value
|
|
||||||
start_value = current_value
|
|
||||||
|
|
||||||
# Insert goal
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO goals (
|
|
||||||
profile_id, goal_type, is_primary,
|
|
||||||
target_value, current_value, start_value, unit,
|
|
||||||
start_date, target_date, category, priority, name, description
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
pid, data.goal_type, data.is_primary,
|
|
||||||
data.target_value, current_value, start_value, data.unit,
|
|
||||||
start_date, data.target_date, data.category, data.priority, data.name, data.description
|
|
||||||
))
|
|
||||||
|
|
||||||
goal_id = cur.fetchone()['id']
|
|
||||||
|
|
||||||
# v2.0: Insert focus area contributions
|
|
||||||
if data.focus_contributions:
|
|
||||||
for contrib in data.focus_contributions:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO goal_focus_contributions
|
|
||||||
(goal_id, focus_area_id, contribution_weight)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
ON CONFLICT (goal_id, focus_area_id) DO UPDATE
|
|
||||||
SET contribution_weight = EXCLUDED.contribution_weight
|
|
||||||
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
|
|
||||||
|
|
||||||
return {"id": goal_id, "message": "Ziel erstellt"}
|
|
||||||
|
|
||||||
@router.put("/{goal_id}")
|
|
||||||
def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_auth)):
|
|
||||||
"""Update existing goal"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Verify ownership
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id FROM goals WHERE id = %s AND profile_id = %s",
|
|
||||||
(goal_id, pid)
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
|
|
||||||
|
|
||||||
# If setting this goal as primary, unset all other primary goals
|
|
||||||
if data.is_primary is True:
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE goals SET is_primary = false WHERE profile_id = %s AND id != %s",
|
|
||||||
(pid, goal_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build update query dynamically
|
|
||||||
updates = []
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if data.target_value is not None:
|
|
||||||
updates.append("target_value = %s")
|
|
||||||
params.append(data.target_value)
|
|
||||||
|
|
||||||
if data.target_date is not None:
|
|
||||||
updates.append("target_date = %s")
|
|
||||||
params.append(data.target_date)
|
|
||||||
|
|
||||||
if data.status is not None:
|
|
||||||
updates.append("status = %s")
|
|
||||||
params.append(data.status)
|
|
||||||
if data.status == 'reached':
|
|
||||||
updates.append("reached_date = CURRENT_DATE")
|
|
||||||
|
|
||||||
if data.is_primary is not None:
|
|
||||||
updates.append("is_primary = %s")
|
|
||||||
params.append(data.is_primary)
|
|
||||||
|
|
||||||
if data.category is not None:
|
|
||||||
updates.append("category = %s")
|
|
||||||
params.append(data.category)
|
|
||||||
|
|
||||||
if data.priority is not None:
|
|
||||||
updates.append("priority = %s")
|
|
||||||
params.append(data.priority)
|
|
||||||
|
|
||||||
if data.name is not None:
|
|
||||||
updates.append("name = %s")
|
|
||||||
params.append(data.name)
|
|
||||||
|
|
||||||
if data.description is not None:
|
|
||||||
updates.append("description = %s")
|
|
||||||
params.append(data.description)
|
|
||||||
|
|
||||||
# Handle start_date and start_value
|
|
||||||
# Determine what start_date and start_value to use
|
|
||||||
final_start_date = None
|
|
||||||
final_start_value = None
|
|
||||||
|
|
||||||
if data.start_date is not None:
|
|
||||||
# User provided a start_date
|
|
||||||
requested_date = data.start_date
|
|
||||||
|
|
||||||
# If start_value not explicitly provided, try to get historical value
|
|
||||||
if data.start_value is None:
|
|
||||||
# Get goal_type for historical lookup
|
|
||||||
cur.execute("SELECT goal_type FROM goals WHERE id = %s", (goal_id,))
|
|
||||||
goal_row = cur.fetchone()
|
|
||||||
if goal_row:
|
|
||||||
goal_type = goal_row['goal_type']
|
|
||||||
historical_data = _get_historical_value_for_goal_type(conn, pid, goal_type, requested_date)
|
|
||||||
|
|
||||||
if historical_data is not None:
|
|
||||||
# Use actual measurement date and value
|
|
||||||
final_start_date = historical_data['date']
|
|
||||||
final_start_value = historical_data['value']
|
|
||||||
print(f"[INFO] Auto-adjusted to first measurement: {final_start_date} = {final_start_value}")
|
|
||||||
else:
|
|
||||||
# No historical data found, use requested date without value
|
|
||||||
final_start_date = requested_date
|
|
||||||
print(f"[WARN] No historical data found for {goal_type} on or after {requested_date}")
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] Could not find goal with id {goal_id}")
|
|
||||||
final_start_date = requested_date
|
|
||||||
else:
|
|
||||||
# User provided both date and value
|
|
||||||
final_start_date = requested_date
|
|
||||||
final_start_value = data.start_value
|
|
||||||
|
|
||||||
elif data.start_value is not None:
|
|
||||||
# Only start_value provided (no date)
|
|
||||||
final_start_value = data.start_value
|
|
||||||
|
|
||||||
# Add to updates if we have values
|
|
||||||
if final_start_date is not None:
|
|
||||||
updates.append("start_date = %s")
|
|
||||||
params.append(final_start_date)
|
|
||||||
|
|
||||||
if final_start_value is not None:
|
|
||||||
updates.append("start_value = %s")
|
|
||||||
params.append(final_start_value)
|
|
||||||
|
|
||||||
# Handle focus_contributions separately (can be updated even if no other changes)
|
|
||||||
if data.focus_contributions is not None:
|
|
||||||
# Delete existing contributions
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM goal_focus_contributions WHERE goal_id = %s",
|
|
||||||
(goal_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert new contributions
|
|
||||||
for contrib in data.focus_contributions:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO goal_focus_contributions
|
|
||||||
(goal_id, focus_area_id, contribution_weight)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
""", (goal_id, contrib.focus_area_id, contrib.contribution_weight))
|
|
||||||
|
|
||||||
if not updates and data.focus_contributions is None:
|
|
||||||
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
|
||||||
|
|
||||||
if updates:
|
|
||||||
updates.append("updated_at = NOW()")
|
|
||||||
params.extend([goal_id, pid])
|
|
||||||
|
|
||||||
update_sql = f"UPDATE goals SET {', '.join(updates)} WHERE id = %s AND profile_id = %s"
|
|
||||||
|
|
||||||
cur.execute(update_sql, tuple(params))
|
|
||||||
|
|
||||||
# Verify what was actually saved
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, goal_type, start_date, start_value, target_date, target_value
|
|
||||||
FROM goals WHERE id = %s
|
|
||||||
""", (goal_id,))
|
|
||||||
saved_goal = cur.fetchone()
|
|
||||||
|
|
||||||
return {"message": "Ziel aktualisiert"}
|
|
||||||
|
|
||||||
@router.delete("/{goal_id}")
|
|
||||||
def delete_goal(goal_id: str, session: dict = Depends(require_auth)):
|
|
||||||
"""Delete goal"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM goals WHERE id = %s AND profile_id = %s",
|
|
||||||
(goal_id, pid)
|
|
||||||
)
|
|
||||||
|
|
||||||
if cur.rowcount == 0:
|
|
||||||
raise HTTPException(status_code=404, detail="Ziel nicht gefunden")
|
|
||||||
|
|
||||||
return {"message": "Ziel gelöscht"}
|
|
||||||
|
|
||||||
@router.get("/grouped")
|
|
||||||
def get_goals_grouped(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Get all goals grouped by category.
|
|
||||||
|
|
||||||
Returns structure:
|
|
||||||
{
|
|
||||||
"body": [{"id": "...", "goal_type": "weight", "priority": 1, ...}, ...],
|
|
||||||
"training": [...],
|
|
||||||
"nutrition": [...],
|
|
||||||
"recovery": [...],
|
|
||||||
"health": [...],
|
|
||||||
"other": [...]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
pid = session['profile_id']
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Get all active goals with type definitions
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
g.id, g.goal_type, g.target_value, g.current_value, g.start_value,
|
|
||||||
g.unit, g.start_date, g.target_date, g.reached_date, g.status,
|
|
||||||
g.is_primary, g.category, g.priority,
|
|
||||||
g.name, g.description, g.progress_pct, g.on_track, g.projection_date,
|
|
||||||
g.created_at, g.updated_at,
|
|
||||||
gt.label_de, gt.icon, gt.category as type_category,
|
|
||||||
gt.source_table, gt.source_column
|
|
||||||
FROM goals g
|
|
||||||
LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key
|
|
||||||
WHERE g.profile_id = %s
|
|
||||||
ORDER BY g.category, g.priority ASC, g.created_at DESC
|
|
||||||
""", (pid,))
|
|
||||||
|
|
||||||
goals = cur.fetchall()
|
|
||||||
|
|
||||||
# v2.0: Load focus_contributions for each goal
|
|
||||||
goal_ids = [g['id'] for g in goals]
|
|
||||||
focus_map = {} # goal_id → [contributions]
|
|
||||||
|
|
||||||
if goal_ids:
|
|
||||||
try:
|
|
||||||
placeholders = ','.join(['%s'] * len(goal_ids))
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT
|
|
||||||
gfc.goal_id, gfc.contribution_weight,
|
|
||||||
fa.id as focus_area_id, fa.key, fa.name_de, fa.icon, fa.category
|
|
||||||
FROM goal_focus_contributions gfc
|
|
||||||
JOIN focus_area_definitions fa ON gfc.focus_area_id = fa.id
|
|
||||||
WHERE gfc.goal_id IN ({placeholders})
|
|
||||||
ORDER BY gfc.contribution_weight DESC
|
|
||||||
""", tuple(goal_ids))
|
|
||||||
|
|
||||||
for row in cur.fetchall():
|
|
||||||
gid = row['goal_id']
|
|
||||||
if gid not in focus_map:
|
|
||||||
focus_map[gid] = []
|
|
||||||
focus_map[gid].append({
|
|
||||||
'focus_area_id': row['focus_area_id'],
|
|
||||||
'key': row['key'],
|
|
||||||
'name_de': row['name_de'],
|
|
||||||
'icon': row['icon'],
|
|
||||||
'category': row['category'],
|
|
||||||
'contribution_weight': float(row['contribution_weight'])
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
# Migration 031 not yet applied - focus_contributions tables don't exist
|
|
||||||
print(f"[WARNING] Could not load focus_contributions: {e}")
|
|
||||||
# Continue without focus_contributions (backward compatible)
|
|
||||||
|
|
||||||
# Group by category and attach focus_contributions
|
|
||||||
grouped = {}
|
|
||||||
for goal in goals:
|
|
||||||
cat = goal['category'] or 'other'
|
|
||||||
if cat not in grouped:
|
|
||||||
grouped[cat] = []
|
|
||||||
|
|
||||||
goal_dict = r2d(goal)
|
|
||||||
goal_dict['focus_contributions'] = focus_map.get(goal['id'], [])
|
|
||||||
grouped[cat].append(goal_dict)
|
|
||||||
|
|
||||||
# Serialize date objects to ISO format strings
|
|
||||||
grouped = serialize_dates(grouped)
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Helper Functions
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _get_current_value_for_goal_type(conn, profile_id: str, goal_type: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Get current value for a goal type.
|
|
||||||
|
|
||||||
DEPRECATED: This function now delegates to the universal fetcher in goal_utils.py.
|
|
||||||
Phase 1.5: All goal types are now defined in goal_type_definitions table.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Database connection
|
|
||||||
profile_id: User's profile ID
|
|
||||||
goal_type: Goal type key (e.g., 'weight', 'meditation_minutes')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Current value or None
|
|
||||||
"""
|
|
||||||
# Delegate to universal fetcher (Phase 1.5)
|
|
||||||
return get_current_value_for_goal(conn, profile_id, goal_type)
|
|
||||||
|
|
||||||
def _get_historical_value_for_goal_type(conn, profile_id: str, goal_type: str, target_date: date) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
Get historical value for a goal type on or after a specific date.
|
|
||||||
Finds the FIRST available measurement >= target_date.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Database connection
|
|
||||||
profile_id: User's profile ID
|
|
||||||
goal_type: Goal type key (e.g., 'weight', 'body_fat')
|
|
||||||
target_date: Desired start date (will find first measurement on or after this date)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with {'value': float, 'date': date} or None if not found
|
|
||||||
"""
|
|
||||||
from goal_utils import get_goal_type_config, get_cursor
|
|
||||||
|
|
||||||
# Get goal type configuration
|
|
||||||
config = get_goal_type_config(conn, goal_type)
|
|
||||||
if not config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
source_table = config.get('source_table')
|
|
||||||
source_column = config.get('source_column')
|
|
||||||
|
|
||||||
|
|
||||||
if not source_table or not source_column:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Query for value closest to target_date (±7 days window)
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Special handling for different tables
|
|
||||||
if source_table == 'vitals_baseline':
|
|
||||||
date_col = 'date'
|
|
||||||
elif source_table == 'blood_pressure_log':
|
|
||||||
date_col = 'recorded_at::date'
|
|
||||||
else:
|
|
||||||
date_col = 'date'
|
|
||||||
|
|
||||||
# Find first measurement on or after target_date
|
|
||||||
query = f"""
|
|
||||||
SELECT {source_column}, {date_col} as measurement_date
|
|
||||||
FROM {source_table}
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND {date_col} >= %s
|
|
||||||
ORDER BY {date_col} ASC
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
params = (profile_id, target_date)
|
|
||||||
|
|
||||||
|
|
||||||
cur.execute(query, params)
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
value = row[source_column]
|
|
||||||
measurement_date = row['measurement_date']
|
|
||||||
|
|
||||||
# Convert Decimal to float
|
|
||||||
result_value = float(value) if value is not None else None
|
|
||||||
|
|
||||||
# Handle different date types (date vs datetime)
|
|
||||||
if hasattr(measurement_date, 'date'):
|
|
||||||
# It's a datetime, extract date
|
|
||||||
result_date = measurement_date.date()
|
|
||||||
else:
|
|
||||||
# It's already a date
|
|
||||||
result_date = measurement_date
|
|
||||||
|
|
||||||
result = {'value': result_value, 'date': result_date}
|
|
||||||
return result
|
|
||||||
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[ERROR] Failed to get historical value for {goal_type} on {target_date}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _update_goal_progress(conn, profile_id: str, goal: dict):
|
|
||||||
"""Update goal progress (modifies goal dict in-place)"""
|
|
||||||
# Get current value
|
|
||||||
current = _get_current_value_for_goal_type(conn, profile_id, goal['goal_type'])
|
|
||||||
|
|
||||||
if current is not None and goal['start_value'] is not None and goal['target_value'] is not None:
|
|
||||||
goal['current_value'] = current
|
|
||||||
|
|
||||||
# Calculate progress percentage
|
|
||||||
total_delta = float(goal['target_value']) - float(goal['start_value'])
|
|
||||||
current_delta = current - float(goal['start_value'])
|
|
||||||
|
|
||||||
if total_delta != 0:
|
|
||||||
progress_pct = (current_delta / total_delta) * 100
|
|
||||||
goal['progress_pct'] = round(progress_pct, 2)
|
|
||||||
|
|
||||||
# Simple linear projection
|
|
||||||
if goal['start_date'] and current_delta != 0:
|
|
||||||
days_elapsed = (date.today() - goal['start_date']).days
|
|
||||||
if days_elapsed > 0:
|
|
||||||
days_per_unit = days_elapsed / current_delta
|
|
||||||
remaining_units = float(goal['target_value']) - current
|
|
||||||
remaining_days = int(days_per_unit * remaining_units)
|
|
||||||
goal['projection_date'] = date.today() + timedelta(days=remaining_days)
|
|
||||||
|
|
||||||
# Check if on track
|
|
||||||
if goal['target_date'] and goal['projection_date']:
|
|
||||||
goal['on_track'] = goal['projection_date'] <= goal['target_date']
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
"""
|
|
||||||
Data Import Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles ZIP import with validation and rollback support.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
from feature_logger import log_feature_usage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/zip")
|
|
||||||
async def import_zip(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Import data from ZIP export file.
|
|
||||||
|
|
||||||
- Validates export format
|
|
||||||
- Imports missing entries only (ON CONFLICT DO NOTHING)
|
|
||||||
- Imports photos
|
|
||||||
- Returns import summary
|
|
||||||
- Full rollback on error
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'data_import')
|
|
||||||
log_feature_usage(pid, 'data_import', access, 'import_zip')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Importe überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Read uploaded file
|
|
||||||
content = await file.read()
|
|
||||||
zip_buffer = io.BytesIO(content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(zip_buffer, 'r') as zf:
|
|
||||||
# 1. Validate profile.json
|
|
||||||
if 'profile.json' not in zf.namelist():
|
|
||||||
raise HTTPException(400, "Ungültiger Export: profile.json fehlt")
|
|
||||||
|
|
||||||
profile_data = json.loads(zf.read('profile.json').decode('utf-8'))
|
|
||||||
export_version = profile_data.get('export_version', '1')
|
|
||||||
|
|
||||||
# Stats tracker
|
|
||||||
stats = {
|
|
||||||
'weight': 0,
|
|
||||||
'circumferences': 0,
|
|
||||||
'caliper': 0,
|
|
||||||
'nutrition': 0,
|
|
||||||
'activity': 0,
|
|
||||||
'photos': 0,
|
|
||||||
'insights': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 2. Import weight.csv
|
|
||||||
if 'data/weight.csv' in zf.namelist():
|
|
||||||
csv_data = zf.read('data/weight.csv').decode('utf-8-sig')
|
|
||||||
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
|
|
||||||
for row in reader:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO weight_log (profile_id, date, weight, note, source, created)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (profile_id, date) DO NOTHING
|
|
||||||
""", (
|
|
||||||
pid,
|
|
||||||
row['date'],
|
|
||||||
float(row['weight']) if row['weight'] else None,
|
|
||||||
row.get('note', ''),
|
|
||||||
row.get('source', 'import'),
|
|
||||||
row.get('created', datetime.now())
|
|
||||||
))
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
stats['weight'] += 1
|
|
||||||
|
|
||||||
# 3. Import circumferences.csv
|
|
||||||
if 'data/circumferences.csv' in zf.namelist():
|
|
||||||
csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig')
|
|
||||||
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
|
|
||||||
for row in reader:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO circumference_log (
|
|
||||||
profile_id, date, c_waist, c_hip, c_chest, c_neck,
|
|
||||||
c_arm, c_thigh, c_calf, notes, created
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (profile_id, date) DO NOTHING
|
|
||||||
""", (
|
|
||||||
pid,
|
|
||||||
row['date'],
|
|
||||||
float(row['waist']) if row.get('waist') else None,
|
|
||||||
float(row['hip']) if row.get('hip') else None,
|
|
||||||
float(row['chest']) if row.get('chest') else None,
|
|
||||||
float(row['neck']) if row.get('neck') else None,
|
|
||||||
float(row['upper_arm']) if row.get('upper_arm') else None,
|
|
||||||
float(row['thigh']) if row.get('thigh') else None,
|
|
||||||
float(row['calf']) if row.get('calf') else None,
|
|
||||||
row.get('note', ''),
|
|
||||||
row.get('created', datetime.now())
|
|
||||||
))
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
stats['circumferences'] += 1
|
|
||||||
|
|
||||||
# 4. Import caliper.csv
|
|
||||||
if 'data/caliper.csv' in zf.namelist():
|
|
||||||
csv_data = zf.read('data/caliper.csv').decode('utf-8-sig')
|
|
||||||
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
|
|
||||||
for row in reader:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO caliper_log (
|
|
||||||
profile_id, date, sf_chest, sf_abdomen, sf_thigh,
|
|
||||||
sf_triceps, sf_subscap, sf_suprailiac, sf_axilla,
|
|
||||||
sf_method, body_fat_pct, notes, created
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (profile_id, date) DO NOTHING
|
|
||||||
""", (
|
|
||||||
pid,
|
|
||||||
row['date'],
|
|
||||||
float(row['chest']) if row.get('chest') else None,
|
|
||||||
float(row['abdomen']) if row.get('abdomen') else None,
|
|
||||||
float(row['thigh']) if row.get('thigh') else None,
|
|
||||||
float(row['tricep']) if row.get('tricep') else None,
|
|
||||||
float(row['subscapular']) if row.get('subscapular') else None,
|
|
||||||
float(row['suprailiac']) if row.get('suprailiac') else None,
|
|
||||||
float(row['midaxillary']) if row.get('midaxillary') else None,
|
|
||||||
row.get('method', 'jackson3'),
|
|
||||||
float(row['bf_percent']) if row.get('bf_percent') else None,
|
|
||||||
row.get('note', ''),
|
|
||||||
row.get('created', datetime.now())
|
|
||||||
))
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
stats['caliper'] += 1
|
|
||||||
|
|
||||||
# 5. Import nutrition.csv
|
|
||||||
if 'data/nutrition.csv' in zf.namelist():
|
|
||||||
csv_data = zf.read('data/nutrition.csv').decode('utf-8-sig')
|
|
||||||
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
|
|
||||||
for row in reader:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO nutrition_log (
|
|
||||||
profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
ON CONFLICT (profile_id, date) DO NOTHING
|
|
||||||
""", (
|
|
||||||
pid,
|
|
||||||
row['date'],
|
|
||||||
float(row['kcal']) if row.get('kcal') else None,
|
|
||||||
float(row['protein']) if row.get('protein') else None,
|
|
||||||
float(row['fat']) if row.get('fat') else None,
|
|
||||||
float(row['carbs']) if row.get('carbs') else None,
|
|
||||||
row.get('source', 'import'),
|
|
||||||
row.get('created', datetime.now())
|
|
||||||
))
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
stats['nutrition'] += 1
|
|
||||||
|
|
||||||
# 6. Import activity.csv
|
|
||||||
if 'data/activity.csv' in zf.namelist():
|
|
||||||
csv_data = zf.read('data/activity.csv').decode('utf-8-sig')
|
|
||||||
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
|
|
||||||
for row in reader:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO activity_log (
|
|
||||||
profile_id, date, activity_type, duration_min,
|
|
||||||
kcal_active, hr_avg, hr_max, distance_km, notes, source, created
|
|
||||||
)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
""", (
|
|
||||||
pid,
|
|
||||||
row['date'],
|
|
||||||
row.get('type', 'Training'),
|
|
||||||
float(row['duration_min']) if row.get('duration_min') else None,
|
|
||||||
float(row['kcal']) if row.get('kcal') else None,
|
|
||||||
float(row['heart_rate_avg']) if row.get('heart_rate_avg') else None,
|
|
||||||
float(row['heart_rate_max']) if row.get('heart_rate_max') else None,
|
|
||||||
float(row['distance_km']) if row.get('distance_km') else None,
|
|
||||||
row.get('note', ''),
|
|
||||||
row.get('source', 'import'),
|
|
||||||
row.get('created', datetime.now())
|
|
||||||
))
|
|
||||||
if cur.rowcount > 0:
|
|
||||||
stats['activity'] += 1
|
|
||||||
|
|
||||||
# 7. Import ai_insights.json
|
|
||||||
if 'insights/ai_insights.json' in zf.namelist():
|
|
||||||
insights_data = json.loads(zf.read('insights/ai_insights.json').decode('utf-8'))
|
|
||||||
for insight in insights_data:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO ai_insights (profile_id, scope, content, created)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
""", (
|
|
||||||
pid,
|
|
||||||
insight['scope'],
|
|
||||||
insight['result'],
|
|
||||||
insight.get('created', datetime.now())
|
|
||||||
))
|
|
||||||
stats['insights'] += 1
|
|
||||||
|
|
||||||
# 8. Import photos
|
|
||||||
photo_files = [f for f in zf.namelist() if f.startswith('photos/') and not f.endswith('/')]
|
|
||||||
for photo_file in photo_files:
|
|
||||||
# Extract date from filename (format: YYYY-MM-DD_N.jpg)
|
|
||||||
filename = Path(photo_file).name
|
|
||||||
parts = filename.split('_')
|
|
||||||
photo_date = parts[0] if len(parts) > 0 else datetime.now().strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Generate new ID and path
|
|
||||||
photo_id = str(uuid.uuid4())
|
|
||||||
ext = Path(filename).suffix
|
|
||||||
new_filename = f"{photo_id}{ext}"
|
|
||||||
target_path = PHOTOS_DIR / new_filename
|
|
||||||
|
|
||||||
# Check if photo already exists for this date
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id FROM photos
|
|
||||||
WHERE profile_id = %s AND date = %s
|
|
||||||
""", (pid, photo_date))
|
|
||||||
|
|
||||||
if cur.fetchone() is None:
|
|
||||||
# Write photo file
|
|
||||||
with open(target_path, 'wb') as f:
|
|
||||||
f.write(zf.read(photo_file))
|
|
||||||
|
|
||||||
# Insert DB record
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO photos (id, profile_id, date, path, created)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
""", (photo_id, pid, photo_date, new_filename, datetime.now()))
|
|
||||||
stats['photos'] += 1
|
|
||||||
|
|
||||||
# Commit transaction
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Rollback on any error
|
|
||||||
conn.rollback()
|
|
||||||
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter
|
|
||||||
increment_feature_usage(pid, 'data_import')
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"message": "Import erfolgreich",
|
|
||||||
"stats": stats,
|
|
||||||
"total": sum(stats.values())
|
|
||||||
}
|
|
||||||
|
|
||||||
except zipfile.BadZipFile:
|
|
||||||
raise HTTPException(400, "Ungültige ZIP-Datei")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, f"Import-Fehler: {str(e)}")
|
|
||||||
|
|
@ -1,671 +0,0 @@
|
||||||
"""
|
|
||||||
AI Insights Endpoints for Mitai Jinkendo
|
|
||||||
|
|
||||||
Handles AI analysis execution, prompt management, and usage tracking.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
import httpx
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
|
|
||||||
from routers.profiles import get_pid
|
|
||||||
from feature_logger import log_feature_usage
|
|
||||||
from quality_filter import get_quality_filter_sql
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["insights"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
|
||||||
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
|
|
||||||
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helper Functions ──────────────────────────────────────────────────────────
|
|
||||||
def check_ai_limit(pid: str):
|
|
||||||
"""Check if profile has reached daily AI limit. Returns (allowed, limit, used)."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT ai_enabled, ai_limit_day FROM profiles WHERE id=%s", (pid,))
|
|
||||||
prof = cur.fetchone()
|
|
||||||
if not prof or not prof['ai_enabled']:
|
|
||||||
raise HTTPException(403, "KI ist für dieses Profil deaktiviert")
|
|
||||||
limit = prof['ai_limit_day']
|
|
||||||
if limit is None:
|
|
||||||
return (True, None, 0)
|
|
||||||
today = datetime.now().date().isoformat()
|
|
||||||
cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
|
|
||||||
usage = cur.fetchone()
|
|
||||||
used = usage['call_count'] if usage else 0
|
|
||||||
if used >= limit:
|
|
||||||
raise HTTPException(429, f"Tägliches KI-Limit erreicht ({limit} Calls)")
|
|
||||||
return (True, limit, used)
|
|
||||||
|
|
||||||
|
|
||||||
def inc_ai_usage(pid: str):
|
|
||||||
"""Increment AI usage counter for today."""
|
|
||||||
today = datetime.now().date().isoformat()
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id, call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
cur.execute("UPDATE ai_usage SET call_count=%s WHERE id=%s", (row['call_count']+1, row['id']))
|
|
||||||
else:
|
|
||||||
cur.execute("INSERT INTO ai_usage (id, profile_id, date, call_count) VALUES (%s,%s,%s,1)",
|
|
||||||
(str(uuid.uuid4()), pid, today))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_profile_data(pid: str):
|
|
||||||
"""Fetch all relevant data for AI analysis."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
||||||
prof = r2d(cur.fetchone())
|
|
||||||
|
|
||||||
# Issue #31: Get global quality filter setting
|
|
||||||
quality_filter = get_quality_filter_sql(prof)
|
|
||||||
cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
|
|
||||||
weight = [r2d(r) for r in cur.fetchall()]
|
|
||||||
cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
|
||||||
circ = [r2d(r) for r in cur.fetchall()]
|
|
||||||
cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
|
||||||
caliper = [r2d(r) for r in cur.fetchall()]
|
|
||||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
|
|
||||||
nutrition = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
# Issue #31: Global quality filter (from user profile setting)
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT * FROM activity_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
{quality_filter}
|
|
||||||
ORDER BY date DESC LIMIT 90
|
|
||||||
""", (pid,))
|
|
||||||
activity = [r2d(r) for r in cur.fetchall()]
|
|
||||||
# v9d Phase 2: Sleep, Rest Days, Vitals
|
|
||||||
cur.execute("SELECT * FROM sleep_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
|
||||||
sleep = [r2d(r) for r in cur.fetchall()]
|
|
||||||
cur.execute("SELECT * FROM rest_days WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
|
||||||
rest_days = [r2d(r) for r in cur.fetchall()]
|
|
||||||
# v9d Phase 2d Refactored: separate baseline and BP tables
|
|
||||||
cur.execute("SELECT * FROM vitals_baseline WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
|
||||||
vitals_baseline = [r2d(r) for r in cur.fetchall()]
|
|
||||||
cur.execute("SELECT * FROM blood_pressure_log WHERE profile_id=%s ORDER BY measured_at DESC LIMIT 90", (pid,))
|
|
||||||
blood_pressure = [r2d(r) for r in cur.fetchall()]
|
|
||||||
return {
|
|
||||||
"profile": prof,
|
|
||||||
"weight": weight,
|
|
||||||
"circumference": circ,
|
|
||||||
"caliper": caliper,
|
|
||||||
"nutrition": nutrition,
|
|
||||||
"activity": activity,
|
|
||||||
"sleep": sleep,
|
|
||||||
"rest_days": rest_days,
|
|
||||||
"vitals_baseline": vitals_baseline,
|
|
||||||
"blood_pressure": blood_pressure
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _render_template(template: str, data: dict) -> str:
|
|
||||||
"""Simple template variable replacement."""
|
|
||||||
result = template
|
|
||||||
for k, v in data.items():
|
|
||||||
result = result.replace(f"{{{{{k}}}}}", str(v) if v is not None else "")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _prepare_template_vars(data: dict) -> dict:
|
|
||||||
"""Prepare template variables from profile data."""
|
|
||||||
prof = data['profile']
|
|
||||||
weight = data['weight']
|
|
||||||
circ = data['circumference']
|
|
||||||
caliper = data['caliper']
|
|
||||||
nutrition = data['nutrition']
|
|
||||||
activity = data['activity']
|
|
||||||
sleep = data.get('sleep', [])
|
|
||||||
rest_days = data.get('rest_days', [])
|
|
||||||
vitals_baseline = data.get('vitals_baseline', [])
|
|
||||||
blood_pressure = data.get('blood_pressure', [])
|
|
||||||
|
|
||||||
vars = {
|
|
||||||
"name": prof.get('name', 'Nutzer'),
|
|
||||||
"geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich",
|
|
||||||
"height": prof.get('height', 178),
|
|
||||||
"goal_weight": float(prof.get('goal_weight')) if prof.get('goal_weight') else "nicht gesetzt",
|
|
||||||
"goal_bf_pct": float(prof.get('goal_bf_pct')) if prof.get('goal_bf_pct') else "nicht gesetzt",
|
|
||||||
"weight_aktuell": float(weight[0]['weight']) if weight else "keine Daten",
|
|
||||||
"kf_aktuell": float(caliper[0]['body_fat_pct']) if caliper and caliper[0].get('body_fat_pct') else "unbekannt",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate age from dob
|
|
||||||
if prof.get('dob'):
|
|
||||||
try:
|
|
||||||
from datetime import date
|
|
||||||
dob = datetime.strptime(prof['dob'], '%Y-%m-%d').date()
|
|
||||||
today = date.today()
|
|
||||||
age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
|
|
||||||
vars['age'] = age
|
|
||||||
except:
|
|
||||||
vars['age'] = "unbekannt"
|
|
||||||
else:
|
|
||||||
vars['age'] = "unbekannt"
|
|
||||||
|
|
||||||
# Weight trend summary
|
|
||||||
if len(weight) >= 2:
|
|
||||||
recent = weight[:30]
|
|
||||||
delta = float(recent[0]['weight']) - float(recent[-1]['weight'])
|
|
||||||
vars['weight_trend'] = f"{len(recent)} Einträge, Δ30d: {delta:+.1f}kg"
|
|
||||||
else:
|
|
||||||
vars['weight_trend'] = "zu wenig Daten"
|
|
||||||
|
|
||||||
# Caliper summary
|
|
||||||
if caliper:
|
|
||||||
c = caliper[0]
|
|
||||||
bf = float(c.get('body_fat_pct')) if c.get('body_fat_pct') else '?'
|
|
||||||
vars['caliper_summary'] = f"KF: {bf}%, Methode: {c.get('sf_method','?')}"
|
|
||||||
else:
|
|
||||||
vars['caliper_summary'] = "keine Daten"
|
|
||||||
|
|
||||||
# Circumference summary
|
|
||||||
if circ:
|
|
||||||
c = circ[0]
|
|
||||||
parts = []
|
|
||||||
for k in ['c_waist', 'c_belly', 'c_hip']:
|
|
||||||
if c.get(k): parts.append(f"{k.split('_')[1]}: {float(c[k])}cm")
|
|
||||||
vars['circ_summary'] = ", ".join(parts) if parts else "keine Daten"
|
|
||||||
else:
|
|
||||||
vars['circ_summary'] = "keine Daten"
|
|
||||||
|
|
||||||
# Nutrition summary
|
|
||||||
if nutrition:
|
|
||||||
n = len(nutrition)
|
|
||||||
avg_kcal = sum(float(d.get('kcal',0) or 0) for d in nutrition) / n
|
|
||||||
avg_prot = sum(float(d.get('protein_g',0) or 0) for d in nutrition) / n
|
|
||||||
vars['nutrition_summary'] = f"{n} Tage, Ø {avg_kcal:.0f}kcal, {avg_prot:.0f}g Protein"
|
|
||||||
vars['nutrition_detail'] = vars['nutrition_summary']
|
|
||||||
vars['nutrition_days'] = n
|
|
||||||
vars['kcal_avg'] = round(avg_kcal)
|
|
||||||
vars['protein_avg'] = round(avg_prot,1)
|
|
||||||
vars['fat_avg'] = round(sum(float(d.get('fat_g',0) or 0) for d in nutrition) / n,1)
|
|
||||||
vars['carb_avg'] = round(sum(float(d.get('carbs_g',0) or 0) for d in nutrition) / n,1)
|
|
||||||
else:
|
|
||||||
vars['nutrition_summary'] = "keine Daten"
|
|
||||||
vars['nutrition_detail'] = "keine Daten"
|
|
||||||
vars['nutrition_days'] = 0
|
|
||||||
vars['kcal_avg'] = 0
|
|
||||||
vars['protein_avg'] = 0
|
|
||||||
vars['fat_avg'] = 0
|
|
||||||
vars['carb_avg'] = 0
|
|
||||||
|
|
||||||
# Protein targets
|
|
||||||
w = weight[0]['weight'] if weight else prof.get('height',178) - 100
|
|
||||||
w = float(w) # Convert Decimal to float for math operations
|
|
||||||
vars['protein_ziel_low'] = round(w * 1.6)
|
|
||||||
vars['protein_ziel_high'] = round(w * 2.2)
|
|
||||||
|
|
||||||
# Activity summary
|
|
||||||
if activity:
|
|
||||||
n = len(activity)
|
|
||||||
total_kcal = sum(float(a.get('kcal_active',0) or 0) for a in activity)
|
|
||||||
vars['activity_summary'] = f"{n} Trainings, {total_kcal:.0f}kcal gesamt"
|
|
||||||
vars['activity_detail'] = vars['activity_summary']
|
|
||||||
vars['activity_kcal_summary'] = f"Ø {total_kcal/n:.0f}kcal/Training"
|
|
||||||
else:
|
|
||||||
vars['activity_summary'] = "keine Daten"
|
|
||||||
vars['activity_detail'] = "keine Daten"
|
|
||||||
vars['activity_kcal_summary'] = "keine Daten"
|
|
||||||
|
|
||||||
# Sleep summary (v9d Phase 2b)
|
|
||||||
if sleep:
|
|
||||||
n = len(sleep)
|
|
||||||
avg_duration = sum(float(s.get('duration_minutes',0) or 0) for s in sleep) / n
|
|
||||||
avg_quality = sum(int(s.get('quality',0) or 0) for s in sleep if s.get('quality')) / max(sum(1 for s in sleep if s.get('quality')), 1)
|
|
||||||
deep_data = [s for s in sleep if s.get('deep_minutes')]
|
|
||||||
avg_deep = sum(float(s.get('deep_minutes',0)) for s in deep_data) / len(deep_data) if deep_data else 0
|
|
||||||
vars['sleep_summary'] = f"{n} Nächte, Ø {avg_duration/60:.1f}h Schlafdauer, Qualität {avg_quality:.1f}/5"
|
|
||||||
vars['sleep_detail'] = f"Ø {avg_duration:.0f}min gesamt, {avg_deep:.0f}min Tiefschlaf"
|
|
||||||
vars['sleep_avg_duration'] = round(avg_duration)
|
|
||||||
vars['sleep_avg_quality'] = round(avg_quality, 1)
|
|
||||||
vars['sleep_nights'] = n
|
|
||||||
else:
|
|
||||||
vars['sleep_summary'] = "keine Daten"
|
|
||||||
vars['sleep_detail'] = "keine Daten"
|
|
||||||
vars['sleep_avg_duration'] = 0
|
|
||||||
vars['sleep_avg_quality'] = 0
|
|
||||||
vars['sleep_nights'] = 0
|
|
||||||
|
|
||||||
# Rest Days summary (v9d Phase 2a)
|
|
||||||
if rest_days:
|
|
||||||
n = len(rest_days)
|
|
||||||
types = {}
|
|
||||||
for rd in rest_days:
|
|
||||||
rt = rd.get('rest_type', 'unknown')
|
|
||||||
types[rt] = types.get(rt, 0) + 1
|
|
||||||
type_summary = ", ".join([f"{k}: {v}x" for k, v in types.items()])
|
|
||||||
vars['rest_days_summary'] = f"{n} Ruhetage (letzte 30d): {type_summary}"
|
|
||||||
vars['rest_days_count'] = n
|
|
||||||
vars['rest_days_types'] = type_summary
|
|
||||||
else:
|
|
||||||
vars['rest_days_summary'] = "keine Daten"
|
|
||||||
vars['rest_days_count'] = 0
|
|
||||||
vars['rest_days_types'] = "keine"
|
|
||||||
|
|
||||||
# Vitals Baseline summary (v9d Phase 2d Refactored)
|
|
||||||
if vitals_baseline:
|
|
||||||
n = len(vitals_baseline)
|
|
||||||
hr_data = [v for v in vitals_baseline if v.get('resting_hr')]
|
|
||||||
hrv_data = [v for v in vitals_baseline if v.get('hrv')]
|
|
||||||
vo2_data = [v for v in vitals_baseline if v.get('vo2_max')]
|
|
||||||
|
|
||||||
avg_hr = sum(int(v.get('resting_hr')) for v in hr_data) / len(hr_data) if hr_data else 0
|
|
||||||
avg_hrv = sum(int(v.get('hrv')) for v in hrv_data) / len(hrv_data) if hrv_data else 0
|
|
||||||
latest_vo2 = float(vo2_data[0].get('vo2_max')) if vo2_data else 0
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
if avg_hr: parts.append(f"Ruhepuls Ø {avg_hr:.0f}bpm")
|
|
||||||
if avg_hrv: parts.append(f"HRV Ø {avg_hrv:.0f}ms")
|
|
||||||
if latest_vo2: parts.append(f"VO2 Max {latest_vo2:.1f}")
|
|
||||||
|
|
||||||
vars['vitals_summary'] = f"{n} Messungen: " + ", ".join(parts) if parts else "keine verwertbaren Daten"
|
|
||||||
vars['vitals_detail'] = vars['vitals_summary']
|
|
||||||
vars['vitals_avg_hr'] = round(avg_hr)
|
|
||||||
vars['vitals_avg_hrv'] = round(avg_hrv)
|
|
||||||
vars['vitals_vo2_max'] = round(latest_vo2, 1) if latest_vo2 else "k.A."
|
|
||||||
else:
|
|
||||||
vars['vitals_summary'] = "keine Daten"
|
|
||||||
vars['vitals_detail'] = "keine Daten"
|
|
||||||
vars['vitals_avg_hr'] = 0
|
|
||||||
vars['vitals_avg_hrv'] = 0
|
|
||||||
vars['vitals_vo2_max'] = "k.A."
|
|
||||||
|
|
||||||
# Blood Pressure summary (v9d Phase 2d Refactored)
|
|
||||||
if blood_pressure:
|
|
||||||
n = len(blood_pressure)
|
|
||||||
bp_data = [bp for bp in blood_pressure if bp.get('systolic') and bp.get('diastolic')]
|
|
||||||
|
|
||||||
avg_bp_sys = sum(int(bp.get('systolic')) for bp in bp_data) / len(bp_data) if bp_data else 0
|
|
||||||
avg_bp_dia = sum(int(bp.get('diastolic')) for bp in bp_data) / len(bp_data) if bp_data else 0
|
|
||||||
|
|
||||||
vars['vitals_avg_bp'] = f"{round(avg_bp_sys)}/{round(avg_bp_dia)}" if avg_bp_sys else "k.A."
|
|
||||||
vars['bp_summary'] = f"{n} Messungen, Ø {avg_bp_sys:.0f}/{avg_bp_dia:.0f} mmHg" if avg_bp_sys else "keine Daten"
|
|
||||||
else:
|
|
||||||
vars['vitals_avg_bp'] = "k.A."
|
|
||||||
vars['bp_summary'] = "keine Daten"
|
|
||||||
|
|
||||||
return vars
|
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
|
||||||
@router.get("/insights")
|
|
||||||
def get_all_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get all AI insights for profile."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
|
||||||
rows = cur.fetchall()
|
|
||||||
return [r2d(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/insights/latest")
|
|
||||||
def get_latest_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get latest AI insights across all scopes."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC LIMIT 10", (pid,))
|
|
||||||
rows = cur.fetchall()
|
|
||||||
return [r2d(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ai/insights/{scope}")
|
|
||||||
def get_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get latest insight for specific scope."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s AND scope=%s ORDER BY created DESC LIMIT 1", (pid,scope))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row: return None
|
|
||||||
return r2d(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/insights/{insight_id}")
|
|
||||||
def delete_insight_by_id(insight_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Delete a specific insight by ID."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("DELETE FROM ai_insights WHERE id=%s AND profile_id=%s", (insight_id, pid))
|
|
||||||
return {"ok":True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/ai/insights/{scope}")
|
|
||||||
def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Delete all insights for specific scope."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid,scope))
|
|
||||||
return {"ok":True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/insights/run/{slug}")
|
|
||||||
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Run AI analysis with specified prompt template."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
|
||||||
access = check_feature_access(pid, 'ai_calls')
|
|
||||||
log_feature_usage(pid, 'ai_calls', access, 'analyze')
|
|
||||||
|
|
||||||
if not access['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get prompt template
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=true", (slug,))
|
|
||||||
prompt_row = cur.fetchone()
|
|
||||||
if not prompt_row:
|
|
||||||
raise HTTPException(404, f"Prompt '{slug}' nicht gefunden")
|
|
||||||
|
|
||||||
prompt_tmpl = prompt_row['template']
|
|
||||||
data = _get_profile_data(pid)
|
|
||||||
vars = _prepare_template_vars(data)
|
|
||||||
final_prompt = _render_template(prompt_tmpl, vars)
|
|
||||||
|
|
||||||
# Call AI
|
|
||||||
if ANTHROPIC_KEY:
|
|
||||||
# Use Anthropic SDK
|
|
||||||
import anthropic
|
|
||||||
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
|
|
||||||
response = client.messages.create(
|
|
||||||
model="claude-sonnet-4-20250514",
|
|
||||||
max_tokens=2000,
|
|
||||||
messages=[{"role": "user", "content": final_prompt}]
|
|
||||||
)
|
|
||||||
content = response.content[0].text
|
|
||||||
elif OPENROUTER_KEY:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": OPENROUTER_MODEL,
|
|
||||||
"messages": [{"role": "user", "content": final_prompt}],
|
|
||||||
"max_tokens": 2000
|
|
||||||
},
|
|
||||||
timeout=60.0
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise HTTPException(500, f"KI-Fehler: {resp.text}")
|
|
||||||
content = resp.json()['choices'][0]['message']['content']
|
|
||||||
else:
|
|
||||||
raise HTTPException(500, "Keine KI-API konfiguriert")
|
|
||||||
|
|
||||||
# Save insight (with history - no DELETE)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
|
||||||
(str(uuid.uuid4()), pid, slug, content))
|
|
||||||
|
|
||||||
# Phase 2: Increment new feature usage counter
|
|
||||||
increment_feature_usage(pid, 'ai_calls')
|
|
||||||
|
|
||||||
# Old usage tracking (keep for now)
|
|
||||||
inc_ai_usage(pid)
|
|
||||||
|
|
||||||
return {"scope": slug, "content": content}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/insights/pipeline")
|
|
||||||
async def analyze_pipeline(
|
|
||||||
config_id: Optional[str] = None,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Run configurable multi-stage pipeline analysis.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config_id: Pipeline config ID (optional, uses default if not specified)
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
|
|
||||||
# Phase 4: Check pipeline feature access (boolean - enabled/disabled)
|
|
||||||
access_pipeline = check_feature_access(pid, 'ai_pipeline')
|
|
||||||
log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline')
|
|
||||||
|
|
||||||
if not access_pipeline['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"ai_pipeline {access_pipeline['reason']}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Also check ai_calls (pipeline uses API calls too)
|
|
||||||
access_calls = check_feature_access(pid, 'ai_calls')
|
|
||||||
log_feature_usage(pid, 'ai_calls', access_calls, 'pipeline_calls')
|
|
||||||
|
|
||||||
if not access_calls['allowed']:
|
|
||||||
logger.warning(
|
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
|
||||||
f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). "
|
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load pipeline config
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if config_id:
|
|
||||||
cur.execute("SELECT * FROM pipeline_configs WHERE id=%s AND active=true", (config_id,))
|
|
||||||
else:
|
|
||||||
cur.execute("SELECT * FROM pipeline_configs WHERE is_default=true AND active=true")
|
|
||||||
|
|
||||||
config = r2d(cur.fetchone())
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(404, "Pipeline-Konfiguration nicht gefunden")
|
|
||||||
|
|
||||||
logger.info(f"[PIPELINE] Using config '{config['name']}' (id={config['id']})")
|
|
||||||
|
|
||||||
data = _get_profile_data(pid)
|
|
||||||
vars = _prepare_template_vars(data)
|
|
||||||
|
|
||||||
# Stage 1: Load and execute prompts from config
|
|
||||||
stage1_prompts = []
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
for slug in config['stage1_prompts']:
|
|
||||||
cur.execute("SELECT slug, template FROM ai_prompts WHERE slug=%s AND active=true", (slug,))
|
|
||||||
prompt = r2d(cur.fetchone())
|
|
||||||
if prompt:
|
|
||||||
stage1_prompts.append(prompt)
|
|
||||||
else:
|
|
||||||
logger.warning(f"[PIPELINE] Stage 1 prompt '{slug}' not found or inactive")
|
|
||||||
|
|
||||||
stage1_results = {}
|
|
||||||
for p in stage1_prompts:
|
|
||||||
slug = p['slug']
|
|
||||||
final_prompt = _render_template(p['template'], vars)
|
|
||||||
|
|
||||||
if ANTHROPIC_KEY:
|
|
||||||
import anthropic
|
|
||||||
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
|
|
||||||
response = client.messages.create(
|
|
||||||
model="claude-sonnet-4-20250514",
|
|
||||||
max_tokens=1000,
|
|
||||||
messages=[{"role": "user", "content": final_prompt}]
|
|
||||||
)
|
|
||||||
content = response.content[0].text.strip()
|
|
||||||
elif OPENROUTER_KEY:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": OPENROUTER_MODEL,
|
|
||||||
"messages": [{"role": "user", "content": final_prompt}],
|
|
||||||
"max_tokens": 1000
|
|
||||||
},
|
|
||||||
timeout=60.0
|
|
||||||
)
|
|
||||||
content = resp.json()['choices'][0]['message']['content'].strip()
|
|
||||||
else:
|
|
||||||
raise HTTPException(500, "Keine KI-API konfiguriert")
|
|
||||||
|
|
||||||
# Try to parse JSON, fallback to raw text
|
|
||||||
try:
|
|
||||||
stage1_results[slug] = json.loads(content)
|
|
||||||
except:
|
|
||||||
stage1_results[slug] = content
|
|
||||||
|
|
||||||
# Stage 2: Synthesis with dynamic placeholders
|
|
||||||
# Inject all stage1 results as {{stage1_<slug>}} placeholders
|
|
||||||
for slug, result in stage1_results.items():
|
|
||||||
# Convert slug like "pipeline_body" to placeholder name "stage1_body"
|
|
||||||
placeholder_name = slug.replace('pipeline_', 'stage1_')
|
|
||||||
vars[placeholder_name] = json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else str(result)
|
|
||||||
|
|
||||||
# Load stage 2 prompt from config
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT template FROM ai_prompts WHERE slug=%s AND active=true", (config['stage2_prompt'],))
|
|
||||||
synth_row = cur.fetchone()
|
|
||||||
if not synth_row:
|
|
||||||
raise HTTPException(500, f"Pipeline synthesis prompt '{config['stage2_prompt']}' not found")
|
|
||||||
|
|
||||||
synth_prompt = _render_template(synth_row['template'], vars)
|
|
||||||
|
|
||||||
if ANTHROPIC_KEY:
|
|
||||||
import anthropic
|
|
||||||
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
|
|
||||||
response = client.messages.create(
|
|
||||||
model="claude-sonnet-4-20250514",
|
|
||||||
max_tokens=2000,
|
|
||||||
messages=[{"role": "user", "content": synth_prompt}]
|
|
||||||
)
|
|
||||||
synthesis = response.content[0].text
|
|
||||||
elif OPENROUTER_KEY:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": OPENROUTER_MODEL,
|
|
||||||
"messages": [{"role": "user", "content": synth_prompt}],
|
|
||||||
"max_tokens": 2000
|
|
||||||
},
|
|
||||||
timeout=60.0
|
|
||||||
)
|
|
||||||
synthesis = resp.json()['choices'][0]['message']['content']
|
|
||||||
else:
|
|
||||||
raise HTTPException(500, "Keine KI-API konfiguriert")
|
|
||||||
|
|
||||||
# Stage 3: Optional (e.g., Goals)
|
|
||||||
goals_text = None
|
|
||||||
if config.get('stage3_prompt'):
|
|
||||||
# Check if conditions are met (for backwards compatibility with goals check)
|
|
||||||
prof = data['profile']
|
|
||||||
should_run_stage3 = True
|
|
||||||
|
|
||||||
# Special case: goals prompt only runs if goals are set
|
|
||||||
if config['stage3_prompt'] == 'pipeline_goals':
|
|
||||||
should_run_stage3 = bool(prof.get('goal_weight') or prof.get('goal_bf_pct'))
|
|
||||||
|
|
||||||
if should_run_stage3:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT template FROM ai_prompts WHERE slug=%s AND active=true", (config['stage3_prompt'],))
|
|
||||||
goals_row = cur.fetchone()
|
|
||||||
if goals_row:
|
|
||||||
goals_prompt = _render_template(goals_row['template'], vars)
|
|
||||||
|
|
||||||
if ANTHROPIC_KEY:
|
|
||||||
import anthropic
|
|
||||||
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
|
|
||||||
response = client.messages.create(
|
|
||||||
model="claude-sonnet-4-20250514",
|
|
||||||
max_tokens=800,
|
|
||||||
messages=[{"role": "user", "content": goals_prompt}]
|
|
||||||
)
|
|
||||||
goals_text = response.content[0].text
|
|
||||||
elif OPENROUTER_KEY:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
|
|
||||||
json={
|
|
||||||
"model": OPENROUTER_MODEL,
|
|
||||||
"messages": [{"role": "user", "content": goals_prompt}],
|
|
||||||
"max_tokens": 800
|
|
||||||
},
|
|
||||||
timeout=60.0
|
|
||||||
)
|
|
||||||
goals_text = resp.json()['choices'][0]['message']['content']
|
|
||||||
|
|
||||||
# Combine synthesis + goals
|
|
||||||
final_content = synthesis
|
|
||||||
if goals_text:
|
|
||||||
final_content += "\n\n" + goals_text
|
|
||||||
|
|
||||||
# Save with config-specific scope (with history - no DELETE)
|
|
||||||
scope = f"pipeline_{config['name'].lower().replace(' ', '_')}"
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
|
||||||
(str(uuid.uuid4()), pid, scope, final_content))
|
|
||||||
|
|
||||||
logger.info(f"[PIPELINE] Completed '{config['name']}' - saved as scope='{scope}'")
|
|
||||||
|
|
||||||
# Phase 2: Increment ai_calls usage (pipeline uses multiple API calls)
|
|
||||||
# Note: We increment once per pipeline run, not per individual call
|
|
||||||
increment_feature_usage(pid, 'ai_calls')
|
|
||||||
|
|
||||||
# Old usage tracking (keep for now)
|
|
||||||
inc_ai_usage(pid)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"scope": scope,
|
|
||||||
"content": final_content,
|
|
||||||
"stage1": stage1_results,
|
|
||||||
"config": {
|
|
||||||
"id": config['id'],
|
|
||||||
"name": config['name']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ai/usage")
|
|
||||||
def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
|
||||||
"""Get AI usage stats for current profile."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT ai_limit_day FROM profiles WHERE id=%s", (pid,))
|
|
||||||
prof = cur.fetchone()
|
|
||||||
limit = prof['ai_limit_day'] if prof else None
|
|
||||||
|
|
||||||
today = datetime.now().date().isoformat()
|
|
||||||
cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
|
|
||||||
usage = cur.fetchone()
|
|
||||||
used = usage['call_count'] if usage else 0
|
|
||||||
|
|
||||||
return {"limit": limit, "used": used, "remaining": (limit - used) if limit else None}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user