Deploy full Fitness AI Agents backend: auth, DB, watch sync, GPS routes, AI analysis
Browse files- backend/auth.py +44 -0
- backend/bots.py +317 -228
- backend/calories.py +48 -0
- backend/db.py +14 -0
- backend/main.py +17 -185
- backend/models/__init__.py +0 -0
- backend/models/analysis.py +19 -0
- backend/models/watch.py +34 -0
- backend/requirements.txt +11 -7
- backend/routes/__init__.py +0 -0
- backend/routes/analysis.py +119 -0
- backend/routes/charts.py +94 -0
- backend/routes/gps.py +159 -0
- backend/routes/integrations.py +1186 -0
- backend/routes/user.py +148 -0
- backend/routes/watch.py +49 -0
backend/auth.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import jwt
|
| 3 |
+
from jwt import PyJWKClient
|
| 4 |
+
from fastapi import Depends, HTTPException, Security
|
| 5 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 6 |
+
|
| 7 |
+
bearer = HTTPBearer()
|
| 8 |
+
|
| 9 |
+
CLERK_JWKS_URL = os.getenv("CLERK_JWKS_URL")
|
| 10 |
+
CLERK_ISSUER_URL = os.getenv("CLERK_ISSUER_URL", "") # e.g. https://pretty-bird-74.clerk.accounts.dev
|
| 11 |
+
|
| 12 |
+
_jwks_client: PyJWKClient | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_jwks_client() -> PyJWKClient:
|
| 16 |
+
global _jwks_client
|
| 17 |
+
if _jwks_client is None:
|
| 18 |
+
_jwks_client = PyJWKClient(CLERK_JWKS_URL, cache_keys=True)
|
| 19 |
+
return _jwks_client
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def verify_token(credentials: HTTPAuthorizationCredentials = Security(bearer)) -> dict:
|
| 23 |
+
token = credentials.credentials
|
| 24 |
+
try:
|
| 25 |
+
signing_key = get_jwks_client().get_signing_key_from_jwt(token)
|
| 26 |
+
decode_kwargs: dict = {
|
| 27 |
+
"algorithms": ["RS256"],
|
| 28 |
+
"options": {"verify_aud": False},
|
| 29 |
+
}
|
| 30 |
+
if CLERK_ISSUER_URL:
|
| 31 |
+
decode_kwargs["issuer"] = CLERK_ISSUER_URL
|
| 32 |
+
payload = jwt.decode(token, signing_key.key, **decode_kwargs)
|
| 33 |
+
return payload
|
| 34 |
+
except jwt.ExpiredSignatureError:
|
| 35 |
+
raise HTTPException(status_code=401, detail="Token expired")
|
| 36 |
+
except jwt.InvalidTokenError as e:
|
| 37 |
+
raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_user_id(payload: dict = Depends(verify_token)) -> str:
|
| 41 |
+
user_id = payload.get("sub")
|
| 42 |
+
if not user_id:
|
| 43 |
+
raise HTTPException(status_code=401, detail="No user ID in token")
|
| 44 |
+
return user_id
|
backend/bots.py
CHANGED
|
@@ -1,26 +1,42 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import os
|
|
|
|
| 4 |
import time as _time
|
|
|
|
|
|
|
|
|
|
| 5 |
from threading import Lock
|
| 6 |
-
from typing import Optional, Literal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from pydantic import BaseModel
|
| 8 |
|
| 9 |
-
import
|
| 10 |
-
|
|
|
|
| 11 |
import litellm
|
| 12 |
-
litellm.cache = None
|
| 13 |
-
litellm.drop_params = True
|
| 14 |
|
|
|
|
| 15 |
# Groq rejects messages that contain a 'cache_breakpoint' property.
|
| 16 |
-
# Two-layer fix:
|
| 17 |
-
# 1. Force caching=False so litellm's client wrapper never injects cache_breakpoint
|
| 18 |
-
# into messages internally (happens when CrewAI passes caching=True in kwargs).
|
| 19 |
-
# 2. Strip cache_breakpoint from any message that already has it (belt + suspenders).
|
| 20 |
_real_completion = litellm.completion
|
| 21 |
|
| 22 |
def _completion_no_cache_breakpoint(*args, **kwargs):
|
| 23 |
-
kwargs["caching"] = False
|
| 24 |
for msg in kwargs.get("messages", []):
|
| 25 |
if isinstance(msg, dict):
|
| 26 |
msg.pop("cache_breakpoint", None)
|
|
@@ -33,53 +49,41 @@ def _completion_no_cache_breakpoint(*args, **kwargs):
|
|
| 33 |
litellm.completion = _completion_no_cache_breakpoint
|
| 34 |
|
| 35 |
# ── Model rotation pools ──────────────────────────────────────────────────────
|
| 36 |
-
# Tier 1 (Groq): 14,400 req/day free, fast, tried first.
|
| 37 |
-
# Tier 2 (OpenRouter :free): shared global rate limits — deep fallback only.
|
| 38 |
_FAST_MODELS = [
|
| 39 |
-
|
| 40 |
-
"groq/
|
| 41 |
-
"groq/
|
| 42 |
-
"
|
| 43 |
-
|
| 44 |
-
"openrouter/
|
| 45 |
-
"openrouter/
|
| 46 |
-
"openrouter/
|
| 47 |
-
"openrouter/
|
| 48 |
-
"openrouter/
|
| 49 |
-
"openrouter/
|
| 50 |
-
"openrouter/meta-llama/llama-4-scout:free", # Meta Llama 4
|
| 51 |
-
"openrouter/microsoft/phi-3-mini-128k-instruct:free", # Microsoft
|
| 52 |
]
|
| 53 |
_SMART_MODELS = [
|
| 54 |
-
|
| 55 |
-
"groq/
|
| 56 |
-
"
|
| 57 |
-
|
| 58 |
-
"openrouter/
|
| 59 |
-
"openrouter/
|
| 60 |
-
"openrouter/meta-llama/llama-
|
| 61 |
-
"openrouter/
|
| 62 |
-
"openrouter/
|
| 63 |
-
"openrouter/mistralai/mistral-small-3.1-24b-instruct:free", # Mistral
|
| 64 |
-
"openrouter/google/gemma-3-12b-it:free", # Google smaller
|
| 65 |
]
|
| 66 |
|
| 67 |
-
|
| 68 |
-
# ── Module-level cooldown state (persists across requests) ───────────────────
|
| 69 |
-
# Groq models share one org-wide TPM quota — rate-limiting one limits all.
|
| 70 |
_GROQ_MODELS_ALL: frozenset = frozenset(
|
| 71 |
m for m in _FAST_MODELS + _SMART_MODELS if m.startswith("groq/")
|
| 72 |
)
|
| 73 |
-
_cooldown: dict[str, float] = {}
|
| 74 |
_cooldown_lock = Lock()
|
| 75 |
-
|
| 76 |
-
# Serialize concurrent HTTP requests so they don't collide on shared Groq TPM.
|
| 77 |
-
# Without this, two simultaneous requests each consume ~12000 TPM and both fail.
|
| 78 |
_crew_lock = Lock()
|
| 79 |
|
| 80 |
|
| 81 |
def _set_cooldown(model: str, seconds: float) -> None:
|
| 82 |
-
"""Mark model unavailable. Groq models cool together (shared org TPM quota)."""
|
| 83 |
until = _time.monotonic() + seconds
|
| 84 |
with _cooldown_lock:
|
| 85 |
if model.startswith("groq/"):
|
|
@@ -90,22 +94,16 @@ def _set_cooldown(model: str, seconds: float) -> None:
|
|
| 90 |
|
| 91 |
|
| 92 |
def _pick_model(pool: list[str]) -> tuple[str, int]:
|
| 93 |
-
"""Return (model, index) of the highest-priority model not in cooldown.
|
| 94 |
-
Always scans from index 0 so higher-priority models (Groq) are preferred
|
| 95 |
-
the moment their cooldown expires. Falls back to soonest-available if all
|
| 96 |
-
are still cooling."""
|
| 97 |
now = _time.monotonic()
|
| 98 |
with _cooldown_lock:
|
| 99 |
for idx, model in enumerate(pool):
|
| 100 |
if _cooldown.get(model, 0.0) <= now:
|
| 101 |
return model, idx
|
| 102 |
-
# All cooling — return the one whose cooldown expires soonest
|
| 103 |
best = min(range(len(pool)), key=lambda i: _cooldown.get(pool[i], 0.0))
|
| 104 |
return pool[best], best
|
| 105 |
|
| 106 |
|
| 107 |
def _wait_until_available() -> None:
|
| 108 |
-
"""Sleep until at least one model in each pool is ready. No-op if already ready."""
|
| 109 |
now = _time.monotonic()
|
| 110 |
with _cooldown_lock:
|
| 111 |
fast_waits = [max(0.0, _cooldown.get(m, 0.0) - now) for m in _FAST_MODELS]
|
|
@@ -117,12 +115,6 @@ def _wait_until_available() -> None:
|
|
| 117 |
|
| 118 |
|
| 119 |
def _parse_retry_after(err_str: str) -> float:
|
| 120 |
-
"""Extract retry delay from provider error string, default 35s.
|
| 121 |
-
|
| 122 |
-
Handles two formats:
|
| 123 |
-
- OpenRouter: retry_after_seconds: 30
|
| 124 |
-
- Groq: Please try again in 2.3s.
|
| 125 |
-
"""
|
| 126 |
m = _re.search(r"retry_after_seconds['\"\s:]+(\d+(?:\.\d+)?)", err_str)
|
| 127 |
if m:
|
| 128 |
return float(m.group(1)) + 5
|
|
@@ -133,26 +125,14 @@ def _parse_retry_after(err_str: str) -> float:
|
|
| 133 |
|
| 134 |
|
| 135 |
def _extract_json(text: str) -> str:
|
| 136 |
-
"""
|
| 137 |
-
Robustly extract a JSON object from LLM output.
|
| 138 |
-
|
| 139 |
-
LLMs often wrap output in markdown fences (```json...```) despite instructions.
|
| 140 |
-
This strips fences first, then uses brace-counting to find the first complete
|
| 141 |
-
JSON object — avoiding greedy regex that matches across multiple objects.
|
| 142 |
-
"""
|
| 143 |
-
# Strip markdown fences
|
| 144 |
text = _re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=_re.MULTILINE)
|
| 145 |
text = _re.sub(r"\s*```$", "", text.strip(), flags=_re.MULTILINE)
|
| 146 |
text = text.strip()
|
| 147 |
-
|
| 148 |
-
# Try parsing the cleaned text directly
|
| 149 |
try:
|
| 150 |
_json.loads(text)
|
| 151 |
return text
|
| 152 |
except _json.JSONDecodeError:
|
| 153 |
pass
|
| 154 |
-
|
| 155 |
-
# Walk characters counting braces to find the first balanced JSON object
|
| 156 |
start = text.find("{")
|
| 157 |
if start != -1:
|
| 158 |
depth = 0
|
|
@@ -175,26 +155,21 @@ def _extract_json(text: str) -> str:
|
|
| 175 |
elif ch == "}":
|
| 176 |
depth -= 1
|
| 177 |
if depth == 0:
|
| 178 |
-
candidate = text[start
|
| 179 |
try:
|
| 180 |
_json.loads(candidate)
|
| 181 |
return candidate
|
| 182 |
except _json.JSONDecodeError:
|
| 183 |
break
|
| 184 |
-
|
| 185 |
return text
|
| 186 |
|
| 187 |
|
| 188 |
-
# LiteLLM reads provider keys from environment automatically for known prefixes.
|
| 189 |
-
# Mirror the OpenRouter key to OPENAI_API_KEY so LiteLLM internal paths that
|
| 190 |
-
# fall back to OpenAI don't error when no OpenAI key is set.
|
| 191 |
_OR_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 192 |
if _OR_KEY:
|
| 193 |
os.environ.setdefault("OPENAI_API_KEY", _OR_KEY)
|
| 194 |
|
| 195 |
|
| 196 |
def _api_key_for(model: str) -> str | None:
|
| 197 |
-
"""Return the correct API key for a given model string."""
|
| 198 |
if model.startswith("groq/"):
|
| 199 |
return os.getenv("GROQ_API_KEY")
|
| 200 |
return os.getenv("OPENROUTER_API_KEY")
|
|
@@ -206,59 +181,103 @@ class DataPoint(BaseModel):
|
|
| 206 |
label: str
|
| 207 |
value: float
|
| 208 |
category: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
class FormattedOutput(BaseModel):
|
| 212 |
"""
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
- summary: 2-3 sentences answering the
|
| 241 |
-
- findings:
|
| 242 |
-
- recommendations:
|
|
|
|
| 243 |
"""
|
| 244 |
-
output_type: Literal["chart", "report"]
|
| 245 |
-
|
|
|
|
| 246 |
chart_title: Optional[str] = None
|
| 247 |
x_axis_label: Optional[str] = None
|
| 248 |
y_axis_label: Optional[str] = None
|
| 249 |
data_points: Optional[list[DataPoint]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
summary: str
|
| 251 |
findings: list[str]
|
| 252 |
recommendations: list[str]
|
|
|
|
|
|
|
| 253 |
|
| 254 |
|
| 255 |
-
# ──
|
| 256 |
|
| 257 |
class Bots:
|
| 258 |
def __init__(self, context: str):
|
| 259 |
self.context = context
|
| 260 |
-
# Escape braces so CrewAI's .format() interpolation doesn't treat user
|
| 261 |
-
# text like "{something}" as a template variable and raise KeyError.
|
| 262 |
self._ctx = context.replace("{", "{{").replace("}", "}}")
|
| 263 |
self._fast_idx = 0
|
| 264 |
self._smart_idx = 0
|
|
@@ -266,8 +285,6 @@ class Bots:
|
|
| 266 |
|
| 267 |
def _smart_llm(self, temperature: float) -> LLM:
|
| 268 |
model = _SMART_MODELS[self._smart_idx % len(_SMART_MODELS)]
|
| 269 |
-
# 1024 keeps total TPM per call under Groq's 12000/min limit when
|
| 270 |
-
# input is large (file contents + long prompt chain).
|
| 271 |
return LLM(
|
| 272 |
model=model,
|
| 273 |
api_key=_api_key_for(model),
|
|
@@ -277,12 +294,12 @@ class Bots:
|
|
| 277 |
temperature=temperature,
|
| 278 |
)
|
| 279 |
|
| 280 |
-
def _fast_llm(self, temperature: float) -> LLM:
|
| 281 |
model = _FAST_MODELS[self._fast_idx % len(_FAST_MODELS)]
|
| 282 |
return LLM(
|
| 283 |
model=model,
|
| 284 |
api_key=_api_key_for(model),
|
| 285 |
-
max_tokens=
|
| 286 |
max_retries=0,
|
| 287 |
timeout=120,
|
| 288 |
temperature=temperature,
|
|
@@ -294,15 +311,11 @@ class Bots:
|
|
| 294 |
goal=(
|
| 295 |
"Read the user's raw context and rewrite it as a precise, unambiguous "
|
| 296 |
"analysis directive. Identify the core question, the most relevant columns "
|
| 297 |
-
"or metrics, and the exact type of analysis needed
|
| 298 |
-
"anomaly, summary, correlation). Output only the directive — nothing else."
|
| 299 |
),
|
| 300 |
backstory=(
|
| 301 |
-
"You
|
| 302 |
-
"
|
| 303 |
-
"what someone actually wants to know versus what they literally said. "
|
| 304 |
-
"You never perform analysis yourself — your only job is to make the analyst's "
|
| 305 |
-
"directive so clear that there is no room for misinterpretation."
|
| 306 |
),
|
| 307 |
tools=[],
|
| 308 |
verbose=True,
|
|
@@ -312,20 +325,35 @@ class Bots:
|
|
| 312 |
cache=False,
|
| 313 |
)
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
self.prompt_engineer = Agent(
|
| 316 |
role="Data Analysis Prompt Engineer",
|
| 317 |
goal=(
|
| 318 |
-
"
|
| 319 |
-
"
|
| 320 |
-
"specify exactly which columns to examine, what calculations to run, what "
|
| 321 |
-
"patterns to look for, and in what order to approach the analysis."
|
| 322 |
),
|
| 323 |
backstory=(
|
| 324 |
-
"You
|
| 325 |
-
"
|
| 326 |
-
"produce vague results. You break every analysis job into clear, ordered steps "
|
| 327 |
-
"with explicit column names, metric names, and success criteria. You never "
|
| 328 |
-
"perform the analysis yourself — you only write the instruction that makes it happen."
|
| 329 |
),
|
| 330 |
tools=[],
|
| 331 |
verbose=True,
|
|
@@ -338,17 +366,12 @@ class Bots:
|
|
| 338 |
self.data_analyst = Agent(
|
| 339 |
role="Senior Data Analyst",
|
| 340 |
goal=(
|
| 341 |
-
"Follow the analysis prompt exactly.
|
| 342 |
-
"
|
| 343 |
-
"If no file is provided, reason from the context and prompt alone. "
|
| 344 |
-
"Never speculate beyond what the data or context shows."
|
| 345 |
),
|
| 346 |
backstory=(
|
| 347 |
-
"You are a rigorous
|
| 348 |
-
"
|
| 349 |
-
"and produces no new information. After reading, you reason directly over the content. "
|
| 350 |
-
"When no file is provided, you produce a thorough analytical response from context alone. "
|
| 351 |
-
"You back every finding with evidence from the data."
|
| 352 |
),
|
| 353 |
tools=[self.file_read],
|
| 354 |
verbose=True,
|
|
@@ -362,22 +385,30 @@ class Bots:
|
|
| 362 |
self.output_formatter = Agent(
|
| 363 |
role="Structured Output Specialist",
|
| 364 |
goal=(
|
| 365 |
-
"Convert
|
| 366 |
-
"
|
| 367 |
-
"be meaningfully compared across 2 or more categories, use 'chart'. Otherwise "
|
| 368 |
-
"use 'report'. Never invent data — only use what the analyst found."
|
| 369 |
),
|
| 370 |
backstory=(
|
| 371 |
-
"You
|
| 372 |
-
"
|
| 373 |
-
"
|
| 374 |
-
"
|
| 375 |
-
"
|
| 376 |
-
"
|
| 377 |
-
"
|
| 378 |
-
"
|
| 379 |
-
"
|
| 380 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
),
|
| 382 |
tools=[],
|
| 383 |
verbose=True,
|
|
@@ -387,64 +418,89 @@ class Bots:
|
|
| 387 |
cache=False,
|
| 388 |
)
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
def create_tasks(self):
|
| 391 |
self.interpret_task = Task(
|
| 392 |
description=(
|
| 393 |
-
f"The user
|
| 394 |
-
|
| 395 |
-
"
|
| 396 |
-
"
|
| 397 |
-
"
|
| 398 |
-
"
|
| 399 |
-
"
|
| 400 |
-
"4. Are there any constraints or focus areas implied by the context "
|
| 401 |
-
"(e.g. a date range, a specific user, a threshold)?\n\n"
|
| 402 |
-
"Write the final directive as 3-5 plain sentences addressed directly to a data analyst."
|
| 403 |
),
|
| 404 |
expected_output=(
|
| 405 |
-
"
|
| 406 |
-
"
|
| 407 |
-
"the relevant columns, the analysis type, and any constraints."
|
| 408 |
),
|
| 409 |
agent=self.context_agent,
|
| 410 |
)
|
| 411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
self.prompt_task = Task(
|
| 413 |
description=(
|
| 414 |
-
f"
|
| 415 |
-
|
| 416 |
-
"
|
| 417 |
-
"
|
| 418 |
-
"
|
| 419 |
-
"
|
| 420 |
-
"
|
| 421 |
-
"4. The order in which to approach the analysis\n"
|
| 422 |
-
"5. What a complete, correct answer looks like"
|
| 423 |
),
|
| 424 |
expected_output=(
|
| 425 |
-
"
|
| 426 |
-
"
|
| 427 |
-
"Must reference exact column names or metric types where possible."
|
| 428 |
),
|
| 429 |
-
context=[self.interpret_task],
|
| 430 |
agent=self.prompt_engineer,
|
| 431 |
)
|
| 432 |
|
| 433 |
self.analyze_task = Task(
|
| 434 |
description=(
|
| 435 |
-
"
|
| 436 |
-
"
|
| 437 |
-
"If
|
| 438 |
-
"If
|
| 439 |
-
"
|
| 440 |
-
"Follow every step in the prompt exactly. Report only what the data shows."
|
| 441 |
),
|
| 442 |
expected_output=(
|
| 443 |
-
"
|
| 444 |
-
"1. Data source summary
|
| 445 |
-
"2. Key statistics
|
| 446 |
-
"3. 3-5 concrete findings that
|
| 447 |
-
"4. 2-3 actionable recommendations
|
| 448 |
),
|
| 449 |
context=[self.prompt_task],
|
| 450 |
agent=self.data_analyst,
|
|
@@ -452,40 +508,53 @@ class Bots:
|
|
| 452 |
|
| 453 |
self.format_task = Task(
|
| 454 |
description=(
|
| 455 |
-
f"
|
| 456 |
-
|
| 457 |
-
"
|
| 458 |
-
" '
|
| 459 |
-
"
|
| 460 |
-
"
|
| 461 |
-
"
|
| 462 |
-
"
|
| 463 |
-
"
|
| 464 |
-
"
|
| 465 |
-
"
|
| 466 |
-
"
|
| 467 |
-
"
|
| 468 |
-
"
|
| 469 |
-
"
|
| 470 |
-
"
|
| 471 |
-
"
|
| 472 |
-
"
|
| 473 |
-
"
|
| 474 |
-
"
|
| 475 |
),
|
| 476 |
expected_output=(
|
| 477 |
-
"
|
| 478 |
-
"No markdown fences
|
| 479 |
-
"The JSON must be parseable by json.loads() without modification."
|
| 480 |
),
|
| 481 |
context=[self.analyze_task],
|
| 482 |
agent=self.output_formatter,
|
| 483 |
output_pydantic=FormattedOutput,
|
| 484 |
)
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
def create_crew(self, data) -> str:
|
| 487 |
-
# Hold the lock for the entire pipeline so a second concurrent request
|
| 488 |
-
# waits rather than hammering the same shared Groq TPM quota.
|
| 489 |
with _crew_lock:
|
| 490 |
return self._run_pipeline(data)
|
| 491 |
|
|
@@ -493,12 +562,7 @@ class Bots:
|
|
| 493 |
max_attempts = (len(_FAST_MODELS) + len(_SMART_MODELS)) * 2
|
| 494 |
|
| 495 |
for attempt in range(max_attempts):
|
| 496 |
-
# Wait BEFORE picking models — ensures cooldowns are honoured
|
| 497 |
-
# even on the very first attempt of a retry cycle.
|
| 498 |
_wait_until_available()
|
| 499 |
-
|
| 500 |
-
# Scan from index 0 every time so Groq (index 0) is always preferred
|
| 501 |
-
# when its cooldown has expired. Cooldowns handle skipping, not the index.
|
| 502 |
fast_model, self._fast_idx = _pick_model(_FAST_MODELS)
|
| 503 |
smart_model, self._smart_idx = _pick_model(_SMART_MODELS)
|
| 504 |
|
|
@@ -506,8 +570,14 @@ class Bots:
|
|
| 506 |
self.create_tasks()
|
| 507 |
|
| 508 |
crew = Crew(
|
| 509 |
-
agents=[
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
process=Process.sequential,
|
| 512 |
verbose=True,
|
| 513 |
memory=False,
|
|
@@ -536,13 +606,32 @@ class Bots:
|
|
| 536 |
_set_cooldown(fast_model, 60)
|
| 537 |
_set_cooldown(smart_model, 60)
|
| 538 |
print(f"[SERVER-ERR] fast={fast_model} smart={smart_model} → 60s cooldown")
|
| 539 |
-
|
| 540 |
-
continue # _wait_until_available() fires at the top of the next iteration
|
| 541 |
raise
|
| 542 |
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
|
| 547 |
raw = result.raw if hasattr(result, "raw") else str(result)
|
| 548 |
return _extract_json(raw)
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DataFlow AI — single-file backend.
|
| 3 |
+
FastAPI SSE server + CrewAI 6-agent pipeline.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
# ── Stdlib ────────────────────────────────────────────────────────────────────
|
| 7 |
+
import sys
|
| 8 |
+
import io
|
| 9 |
+
import queue
|
| 10 |
+
import threading
|
| 11 |
+
import tempfile
|
| 12 |
import os
|
| 13 |
+
import re as _re
|
| 14 |
import time as _time
|
| 15 |
+
import asyncio
|
| 16 |
+
import json
|
| 17 |
+
import json as _json
|
| 18 |
from threading import Lock
|
| 19 |
+
from typing import Optional, Literal, List as _List
|
| 20 |
+
|
| 21 |
+
# ── Third-party ───────────────────────────────────────────────────────────────
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
from pydantic import BaseModel
|
| 26 |
|
| 27 |
+
from crewai import Agent, Crew, Task, Process, LLM
|
| 28 |
+
from crewai_tools import FileReadTool
|
| 29 |
+
|
| 30 |
import litellm
|
| 31 |
+
litellm.cache = None
|
| 32 |
+
litellm.drop_params = True
|
| 33 |
|
| 34 |
+
# ── Groq cache_breakpoint patch ───────────────────────────────────────────────
|
| 35 |
# Groq rejects messages that contain a 'cache_breakpoint' property.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
_real_completion = litellm.completion
|
| 37 |
|
| 38 |
def _completion_no_cache_breakpoint(*args, **kwargs):
|
| 39 |
+
kwargs["caching"] = False
|
| 40 |
for msg in kwargs.get("messages", []):
|
| 41 |
if isinstance(msg, dict):
|
| 42 |
msg.pop("cache_breakpoint", None)
|
|
|
|
| 49 |
litellm.completion = _completion_no_cache_breakpoint
|
| 50 |
|
| 51 |
# ── Model rotation pools ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 52 |
_FAST_MODELS = [
|
| 53 |
+
"groq/llama-3.1-8b-instant",
|
| 54 |
+
"groq/gemma2-9b-it",
|
| 55 |
+
"groq/llama3-8b-8192",
|
| 56 |
+
"openrouter/nvidia/nemotron-nano-9b-v2:free",
|
| 57 |
+
"openrouter/minimax/minimax-m2.5:free",
|
| 58 |
+
"openrouter/meta-llama/llama-3.1-8b-instruct:free",
|
| 59 |
+
"openrouter/mistralai/mistral-7b-instruct:free",
|
| 60 |
+
"openrouter/google/gemma-3-12b-it:free",
|
| 61 |
+
"openrouter/qwen/qwen3-8b:free",
|
| 62 |
+
"openrouter/meta-llama/llama-4-scout:free",
|
| 63 |
+
"openrouter/microsoft/phi-3-mini-128k-instruct:free",
|
|
|
|
|
|
|
| 64 |
]
|
| 65 |
_SMART_MODELS = [
|
| 66 |
+
"groq/llama-3.3-70b-versatile",
|
| 67 |
+
"groq/llama3-70b-8192",
|
| 68 |
+
"openrouter/google/gemma-3-27b-it:free",
|
| 69 |
+
"openrouter/qwen/qwen3-coder:free",
|
| 70 |
+
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
| 71 |
+
"openrouter/deepseek/deepseek-chat-v3-0324:free",
|
| 72 |
+
"openrouter/meta-llama/llama-4-maverick:free",
|
| 73 |
+
"openrouter/mistralai/mistral-small-3.1-24b-instruct:free",
|
| 74 |
+
"openrouter/google/gemma-3-12b-it:free",
|
|
|
|
|
|
|
| 75 |
]
|
| 76 |
|
| 77 |
+
# ── Cooldown state ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 78 |
_GROQ_MODELS_ALL: frozenset = frozenset(
|
| 79 |
m for m in _FAST_MODELS + _SMART_MODELS if m.startswith("groq/")
|
| 80 |
)
|
| 81 |
+
_cooldown: dict[str, float] = {}
|
| 82 |
_cooldown_lock = Lock()
|
|
|
|
|
|
|
|
|
|
| 83 |
_crew_lock = Lock()
|
| 84 |
|
| 85 |
|
| 86 |
def _set_cooldown(model: str, seconds: float) -> None:
|
|
|
|
| 87 |
until = _time.monotonic() + seconds
|
| 88 |
with _cooldown_lock:
|
| 89 |
if model.startswith("groq/"):
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def _pick_model(pool: list[str]) -> tuple[str, int]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
now = _time.monotonic()
|
| 98 |
with _cooldown_lock:
|
| 99 |
for idx, model in enumerate(pool):
|
| 100 |
if _cooldown.get(model, 0.0) <= now:
|
| 101 |
return model, idx
|
|
|
|
| 102 |
best = min(range(len(pool)), key=lambda i: _cooldown.get(pool[i], 0.0))
|
| 103 |
return pool[best], best
|
| 104 |
|
| 105 |
|
| 106 |
def _wait_until_available() -> None:
|
|
|
|
| 107 |
now = _time.monotonic()
|
| 108 |
with _cooldown_lock:
|
| 109 |
fast_waits = [max(0.0, _cooldown.get(m, 0.0) - now) for m in _FAST_MODELS]
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
def _parse_retry_after(err_str: str) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
m = _re.search(r"retry_after_seconds['\"\s:]+(\d+(?:\.\d+)?)", err_str)
|
| 119 |
if m:
|
| 120 |
return float(m.group(1)) + 5
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
def _extract_json(text: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
text = _re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=_re.MULTILINE)
|
| 129 |
text = _re.sub(r"\s*```$", "", text.strip(), flags=_re.MULTILINE)
|
| 130 |
text = text.strip()
|
|
|
|
|
|
|
| 131 |
try:
|
| 132 |
_json.loads(text)
|
| 133 |
return text
|
| 134 |
except _json.JSONDecodeError:
|
| 135 |
pass
|
|
|
|
|
|
|
| 136 |
start = text.find("{")
|
| 137 |
if start != -1:
|
| 138 |
depth = 0
|
|
|
|
| 155 |
elif ch == "}":
|
| 156 |
depth -= 1
|
| 157 |
if depth == 0:
|
| 158 |
+
candidate = text[start: i + 1]
|
| 159 |
try:
|
| 160 |
_json.loads(candidate)
|
| 161 |
return candidate
|
| 162 |
except _json.JSONDecodeError:
|
| 163 |
break
|
|
|
|
| 164 |
return text
|
| 165 |
|
| 166 |
|
|
|
|
|
|
|
|
|
|
| 167 |
_OR_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 168 |
if _OR_KEY:
|
| 169 |
os.environ.setdefault("OPENAI_API_KEY", _OR_KEY)
|
| 170 |
|
| 171 |
|
| 172 |
def _api_key_for(model: str) -> str | None:
|
|
|
|
| 173 |
if model.startswith("groq/"):
|
| 174 |
return os.getenv("GROQ_API_KEY")
|
| 175 |
return os.getenv("OPENROUTER_API_KEY")
|
|
|
|
| 181 |
label: str
|
| 182 |
value: float
|
| 183 |
category: Optional[str] = None
|
| 184 |
+
x_value: Optional[float] = None # scatter: second axis
|
| 185 |
+
value2: Optional[float] = None # radar: second series value
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class CodeBlock(BaseModel):
|
| 189 |
+
language: str # python | sql | bash | r | javascript
|
| 190 |
+
title: str
|
| 191 |
+
code: str # keep under 400 chars
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class MetricItem(BaseModel):
|
| 195 |
+
label: str
|
| 196 |
+
value: str # formatted string, e.g. "1,234" or "98.5%"
|
| 197 |
+
unit: Optional[str] = None
|
| 198 |
+
trend: Optional[str] = None # up | down | neutral
|
| 199 |
+
change: Optional[str] = None # e.g. "+12%"
|
| 200 |
+
context: Optional[str] = None # e.g. "vs last week"
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class ComparisonRow(BaseModel):
|
| 204 |
+
metric: str # e.g. "Avg Revenue"
|
| 205 |
+
value_a: str # formatted value for entity A
|
| 206 |
+
value_b: str # formatted value for entity B
|
| 207 |
+
winner: Optional[Literal["a", "b", "tie"]] = None
|
| 208 |
|
| 209 |
|
| 210 |
class FormattedOutput(BaseModel):
|
| 211 |
"""
|
| 212 |
+
OUTPUT TYPE DECISION RULES — pick the FIRST that matches:
|
| 213 |
+
1. "code" → answer is or includes runnable code/queries/scripts.
|
| 214 |
+
Populate code_blocks (1-3 blocks).
|
| 215 |
+
2. "metrics" → answer is a set of KPIs or key numbers (3-8 items).
|
| 216 |
+
Populate metrics list.
|
| 217 |
+
3. "comparison" → comparing two named entities side-by-side across metrics.
|
| 218 |
+
Populate comparison_a_label, comparison_b_label, comparison_rows.
|
| 219 |
+
4. "heatmap" → data is a matrix (rows × columns) of numeric values — e.g.
|
| 220 |
+
a correlation matrix, time-of-day × day-of-week activity grid,
|
| 221 |
+
or category × category frequency table.
|
| 222 |
+
Populate heatmap_row_labels, heatmap_col_labels, heatmap_values.
|
| 223 |
+
Max 10 rows × 10 cols.
|
| 224 |
+
5. "table" → ranked/multi-attribute list best shown as labelled rows+columns.
|
| 225 |
+
Populate table_headers and table_rows (max 20 rows).
|
| 226 |
+
6. "chart" → 2+ numeric values that can be compared visually.
|
| 227 |
+
Choose chart_type:
|
| 228 |
+
bar → named categories
|
| 229 |
+
line → sequential time periods
|
| 230 |
+
pie → parts of a whole, 2-6 slices
|
| 231 |
+
scatter → correlation (set x_value + value per DataPoint)
|
| 232 |
+
funnel → sequential stages with drop-off (conversion pipelines)
|
| 233 |
+
radar → multi-attribute profile comparison across dimensions;
|
| 234 |
+
set value for series A; set value2 + radar_b_label
|
| 235 |
+
if comparing two entities on the same axes.
|
| 236 |
+
7. "report" → qualitative or narrative findings.
|
| 237 |
+
|
| 238 |
+
ALWAYS REQUIRED:
|
| 239 |
+
- summary: 2-3 sentences directly answering the original question.
|
| 240 |
+
- findings: 3-5 specific factual strings from the data.
|
| 241 |
+
- recommendations: 2-3 actionable strings.
|
| 242 |
+
- Set unused fields to null.
|
| 243 |
"""
|
| 244 |
+
output_type: Literal["chart", "report", "code", "table", "metrics", "comparison", "heatmap"]
|
| 245 |
+
# chart
|
| 246 |
+
chart_type: Optional[Literal["bar", "line", "pie", "scatter", "funnel", "radar"]] = None
|
| 247 |
chart_title: Optional[str] = None
|
| 248 |
x_axis_label: Optional[str] = None
|
| 249 |
y_axis_label: Optional[str] = None
|
| 250 |
data_points: Optional[list[DataPoint]] = None
|
| 251 |
+
radar_b_label: Optional[str] = None # label for value2 series in radar
|
| 252 |
+
# code
|
| 253 |
+
code_blocks: Optional[list[CodeBlock]] = None
|
| 254 |
+
# table
|
| 255 |
+
table_headers: Optional[list[str]] = None
|
| 256 |
+
table_rows: Optional[list[list[str]]] = None
|
| 257 |
+
# metrics
|
| 258 |
+
metrics: Optional[list[MetricItem]] = None
|
| 259 |
+
# comparison
|
| 260 |
+
comparison_a_label: Optional[str] = None
|
| 261 |
+
comparison_b_label: Optional[str] = None
|
| 262 |
+
comparison_rows: Optional[list[ComparisonRow]] = None
|
| 263 |
+
# heatmap
|
| 264 |
+
heatmap_title: Optional[str] = None
|
| 265 |
+
heatmap_row_labels: Optional[list[str]] = None
|
| 266 |
+
heatmap_col_labels: Optional[list[str]] = None
|
| 267 |
+
heatmap_values: Optional[list[list[float]]] = None # [row_idx][col_idx]
|
| 268 |
+
# always
|
| 269 |
summary: str
|
| 270 |
findings: list[str]
|
| 271 |
recommendations: list[str]
|
| 272 |
+
quality_score: Optional[int] = None
|
| 273 |
+
quality_verdict: Optional[str] = None
|
| 274 |
|
| 275 |
|
| 276 |
+
# ── Agent pipeline ────────────────────────────────────────────────────────────
|
| 277 |
|
| 278 |
class Bots:
|
| 279 |
def __init__(self, context: str):
|
| 280 |
self.context = context
|
|
|
|
|
|
|
| 281 |
self._ctx = context.replace("{", "{{").replace("}", "}}")
|
| 282 |
self._fast_idx = 0
|
| 283 |
self._smart_idx = 0
|
|
|
|
| 285 |
|
| 286 |
def _smart_llm(self, temperature: float) -> LLM:
|
| 287 |
model = _SMART_MODELS[self._smart_idx % len(_SMART_MODELS)]
|
|
|
|
|
|
|
| 288 |
return LLM(
|
| 289 |
model=model,
|
| 290 |
api_key=_api_key_for(model),
|
|
|
|
| 294 |
temperature=temperature,
|
| 295 |
)
|
| 296 |
|
| 297 |
+
def _fast_llm(self, temperature: float, max_tokens: int = 1024) -> LLM:
|
| 298 |
model = _FAST_MODELS[self._fast_idx % len(_FAST_MODELS)]
|
| 299 |
return LLM(
|
| 300 |
model=model,
|
| 301 |
api_key=_api_key_for(model),
|
| 302 |
+
max_tokens=max_tokens,
|
| 303 |
max_retries=0,
|
| 304 |
timeout=120,
|
| 305 |
temperature=temperature,
|
|
|
|
| 311 |
goal=(
|
| 312 |
"Read the user's raw context and rewrite it as a precise, unambiguous "
|
| 313 |
"analysis directive. Identify the core question, the most relevant columns "
|
| 314 |
+
"or metrics, and the exact type of analysis needed."
|
|
|
|
| 315 |
),
|
| 316 |
backstory=(
|
| 317 |
+
"You translate vague requests into sharp, actionable instructions. "
|
| 318 |
+
"You never perform analysis — you only clarify the directive."
|
|
|
|
|
|
|
|
|
|
| 319 |
),
|
| 320 |
tools=[],
|
| 321 |
verbose=True,
|
|
|
|
| 325 |
cache=False,
|
| 326 |
)
|
| 327 |
|
| 328 |
+
self.data_cleaner = Agent(
|
| 329 |
+
role="Data Quality Inspector",
|
| 330 |
+
goal=(
|
| 331 |
+
"Read every file provided and produce a concise data quality report: "
|
| 332 |
+
"column names, row count, missing values, duplicate rows, data type issues. "
|
| 333 |
+
"Keep under 200 words."
|
| 334 |
+
),
|
| 335 |
+
backstory=(
|
| 336 |
+
"You are a meticulous data auditor. You use FileReadTool once per file, "
|
| 337 |
+
"then summarise its structure and flag obvious problems."
|
| 338 |
+
),
|
| 339 |
+
tools=[self.file_read],
|
| 340 |
+
verbose=True,
|
| 341 |
+
memory=False,
|
| 342 |
+
max_iter=5,
|
| 343 |
+
llm=self._fast_llm(0.1, max_tokens=512),
|
| 344 |
+
allow_delegation=False,
|
| 345 |
+
cache=False,
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
self.prompt_engineer = Agent(
|
| 349 |
role="Data Analysis Prompt Engineer",
|
| 350 |
goal=(
|
| 351 |
+
"Construct a precise, step-by-step analysis prompt for the data analyst. "
|
| 352 |
+
"Specify exact columns, calculations, patterns to look for, and order of steps."
|
|
|
|
|
|
|
| 353 |
),
|
| 354 |
backstory=(
|
| 355 |
+
"You write technical prompts for data analysis pipelines. "
|
| 356 |
+
"Vague instructions produce vague results — you are never vague."
|
|
|
|
|
|
|
|
|
|
| 357 |
),
|
| 358 |
tools=[],
|
| 359 |
verbose=True,
|
|
|
|
| 366 |
self.data_analyst = Agent(
|
| 367 |
role="Senior Data Analyst",
|
| 368 |
goal=(
|
| 369 |
+
"Follow the analysis prompt exactly. Call FileReadTool ONCE per file path. "
|
| 370 |
+
"Reason over the content to answer the prompt. Never speculate beyond the data."
|
|
|
|
|
|
|
| 371 |
),
|
| 372 |
backstory=(
|
| 373 |
+
"You are a rigorous analyst. You call FileReadTool exactly once per file — "
|
| 374 |
+
"re-reading wastes tokens. You back every finding with evidence."
|
|
|
|
|
|
|
|
|
|
| 375 |
),
|
| 376 |
tools=[self.file_read],
|
| 377 |
verbose=True,
|
|
|
|
| 385 |
self.output_formatter = Agent(
|
| 386 |
role="Structured Output Specialist",
|
| 387 |
goal=(
|
| 388 |
+
"Convert analyst findings into a strict FormattedOutput JSON object. "
|
| 389 |
+
"Choose output_type by priority: code → metrics → comparison → heatmap → table → chart → report."
|
|
|
|
|
|
|
| 390 |
),
|
| 391 |
backstory=(
|
| 392 |
+
"You serialise analysis results into one of 7 output modes:\n"
|
| 393 |
+
"• code — runnable scripts, queries, or algorithms\n"
|
| 394 |
+
"• metrics — key numbers / KPIs (3-8 items)\n"
|
| 395 |
+
"• comparison — two named entities compared across metrics\n"
|
| 396 |
+
"• heatmap — matrix of values (correlation, frequency, activity)\n"
|
| 397 |
+
"• table — ranked/multi-attribute list (max 20 rows)\n"
|
| 398 |
+
"• chart — bar, line, pie, scatter, funnel, or radar\n"
|
| 399 |
+
"• report — qualitative or narrative findings\n\n"
|
| 400 |
+
"CHART TYPE SELECTION:\n"
|
| 401 |
+
" funnel → sequential conversion stages with drop-off\n"
|
| 402 |
+
" radar → multi-attribute profile (use value2+radar_b_label for dual series)\n"
|
| 403 |
+
" scatter → correlation (x_value + value per point)\n"
|
| 404 |
+
" pie → 2-6 parts of a whole\n"
|
| 405 |
+
" line → time series\n"
|
| 406 |
+
" bar → named categories\n\n"
|
| 407 |
+
"COMPARISON: comparison_a_label and comparison_b_label name the two entities. "
|
| 408 |
+
"Each comparison_row has metric, value_a, value_b, and winner (a/b/tie).\n\n"
|
| 409 |
+
"HEATMAP: heatmap_values is a 2D list [row][col] of floats. Max 10×10.\n\n"
|
| 410 |
+
"Output ONLY the raw JSON object. No markdown fences, no preamble. "
|
| 411 |
+
"summary=2-3 sentences, findings=3-5 strings, recommendations=2-3 strings."
|
| 412 |
),
|
| 413 |
tools=[],
|
| 414 |
verbose=True,
|
|
|
|
| 418 |
cache=False,
|
| 419 |
)
|
| 420 |
|
| 421 |
+
self.qa_critic = Agent(
|
| 422 |
+
role="Analysis Quality Critic",
|
| 423 |
+
goal=(
|
| 424 |
+
"Rate how well the analysis answered the original question. "
|
| 425 |
+
'Output ONLY: {"score": <int 1-10>, "verdict": "<1-2 sentences>"}'
|
| 426 |
+
),
|
| 427 |
+
backstory=(
|
| 428 |
+
"You review analyses for completeness, specificity, and evidence quality. "
|
| 429 |
+
"Score 10 = every aspect answered with data. Score <5 = question not answered. "
|
| 430 |
+
"Output ONLY the raw JSON — no markdown, no preamble."
|
| 431 |
+
),
|
| 432 |
+
tools=[],
|
| 433 |
+
verbose=True,
|
| 434 |
+
memory=False,
|
| 435 |
+
llm=self._fast_llm(0.2, max_tokens=512),
|
| 436 |
+
allow_delegation=False,
|
| 437 |
+
cache=False,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
def create_tasks(self):
|
| 441 |
self.interpret_task = Task(
|
| 442 |
description=(
|
| 443 |
+
f"The user wants: {self._ctx}\n\n"
|
| 444 |
+
"Rewrite this into a structured analysis directive:\n"
|
| 445 |
+
"1. The single core question to answer\n"
|
| 446 |
+
"2. Relevant columns/metrics\n"
|
| 447 |
+
"3. Analysis type (trend, comparison, anomaly, summary, correlation)\n"
|
| 448 |
+
"4. Any constraints (date range, thresholds, focus areas)\n\n"
|
| 449 |
+
"Write 3-5 plain sentences addressed directly to a data analyst."
|
|
|
|
|
|
|
|
|
|
| 450 |
),
|
| 451 |
expected_output=(
|
| 452 |
+
"3-5 plain sentences. No headers, no bullets. "
|
| 453 |
+
"Direct instruction specifying: the question, relevant columns, analysis type, constraints."
|
|
|
|
| 454 |
),
|
| 455 |
agent=self.context_agent,
|
| 456 |
)
|
| 457 |
|
| 458 |
+
self.clean_task = Task(
|
| 459 |
+
description=(
|
| 460 |
+
"Dataset path(s):\n{data}\n\n"
|
| 461 |
+
"If {data} is not '(no file)', use FileReadTool to read each path once.\n"
|
| 462 |
+
"Report per file: type/size, column names, row count, missing values, "
|
| 463 |
+
"obvious issues, 2 sample records.\n"
|
| 464 |
+
"If no file: report 'No file provided — analysis uses context only.'\n"
|
| 465 |
+
"Keep under 200 words."
|
| 466 |
+
),
|
| 467 |
+
expected_output="Concise data quality report under 200 words. Plain prose or bullets. No JSON.",
|
| 468 |
+
context=[self.interpret_task],
|
| 469 |
+
agent=self.data_cleaner,
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
self.prompt_task = Task(
|
| 473 |
description=(
|
| 474 |
+
f"Original request: {self._ctx}\n\n"
|
| 475 |
+
"Using the directive and data quality report, write a step-by-step analysis prompt:\n"
|
| 476 |
+
"1. Exact columns to load\n"
|
| 477 |
+
"2. Calculations/aggregations to run\n"
|
| 478 |
+
"3. Patterns, outliers, or trends to look for\n"
|
| 479 |
+
"4. Order of approach\n"
|
| 480 |
+
"5. What a complete answer looks like"
|
|
|
|
|
|
|
| 481 |
),
|
| 482 |
expected_output=(
|
| 483 |
+
"Numbered step-by-step prompt. Each step specific and actionable. "
|
| 484 |
+
"References exact column names where possible."
|
|
|
|
| 485 |
),
|
| 486 |
+
context=[self.interpret_task, self.clean_task],
|
| 487 |
agent=self.prompt_engineer,
|
| 488 |
)
|
| 489 |
|
| 490 |
self.analyze_task = Task(
|
| 491 |
description=(
|
| 492 |
+
"Dataset path(s):\n{data}\n\n"
|
| 493 |
+
"If file paths are provided (one per line), read each with FileReadTool exactly once. "
|
| 494 |
+
"If multiple files, analyze together. "
|
| 495 |
+
"If no file, answer from the analysis prompt using reasoning.\n\n"
|
| 496 |
+
"Follow every step in the prompt. Read each file only once. Report only what the data shows."
|
|
|
|
| 497 |
),
|
| 498 |
expected_output=(
|
| 499 |
+
"Thorough analysis containing:\n"
|
| 500 |
+
"1. Data source summary\n"
|
| 501 |
+
"2. Key statistics (averages, ranges, counts, outliers)\n"
|
| 502 |
+
"3. 3-5 concrete findings that answer the prompt\n"
|
| 503 |
+
"4. 2-3 actionable recommendations"
|
| 504 |
),
|
| 505 |
context=[self.prompt_task],
|
| 506 |
agent=self.data_analyst,
|
|
|
|
| 508 |
|
| 509 |
self.format_task = Task(
|
| 510 |
description=(
|
| 511 |
+
f"Original request: {self._ctx}\n\n"
|
| 512 |
+
"Convert the analyst's findings into a FormattedOutput JSON object.\n\n"
|
| 513 |
+
"OUTPUT TYPE PRIORITY (pick first that fits):\n"
|
| 514 |
+
" 'code' → answer is or includes runnable code/queries/scripts\n"
|
| 515 |
+
" 'metrics' → answer is a set of KPIs or key numbers (3-8 items)\n"
|
| 516 |
+
" 'comparison' → comparing two named entities across multiple metrics;\n"
|
| 517 |
+
" set comparison_a_label, comparison_b_label, comparison_rows\n"
|
| 518 |
+
" (each row: metric, value_a, value_b, winner='a'/'b'/'tie')\n"
|
| 519 |
+
" 'heatmap' → data is a matrix (rows × cols) of numeric values;\n"
|
| 520 |
+
" set heatmap_row_labels, heatmap_col_labels,\n"
|
| 521 |
+
" heatmap_values (2D float list), heatmap_title. Max 10×10.\n"
|
| 522 |
+
" 'table' → ranked/multi-attribute list, max 20 rows\n"
|
| 523 |
+
" 'chart' → visual comparison of 2+ values; chart_type options:\n"
|
| 524 |
+
" bar, line, pie, scatter, funnel, radar\n"
|
| 525 |
+
" For funnel: stages in order, value = count/rate at each stage\n"
|
| 526 |
+
" For radar: label=axis, value=series A; optionally value2=series B\n"
|
| 527 |
+
" and set radar_b_label for B's name\n"
|
| 528 |
+
" 'report' → qualitative/narrative findings\n\n"
|
| 529 |
+
"ALWAYS: summary (2-3 sentences), findings (3-5 strings), recommendations (2-3 strings).\n"
|
| 530 |
+
"Return ONLY the raw JSON object. No markdown, no commentary."
|
| 531 |
),
|
| 532 |
expected_output=(
|
| 533 |
+
"Single raw JSON object matching FormattedOutput. "
|
| 534 |
+
"No markdown fences. Parseable by json.loads() without modification."
|
|
|
|
| 535 |
),
|
| 536 |
context=[self.analyze_task],
|
| 537 |
agent=self.output_formatter,
|
| 538 |
output_pydantic=FormattedOutput,
|
| 539 |
)
|
| 540 |
|
| 541 |
+
self.qa_task = Task(
|
| 542 |
+
description=(
|
| 543 |
+
f"Original request: {self._ctx}\n\n"
|
| 544 |
+
"Review the completed analysis. Score 1-10 based on:\n"
|
| 545 |
+
"- Did it directly and specifically answer the original question?\n"
|
| 546 |
+
"- Are findings backed by concrete numbers from the data?\n"
|
| 547 |
+
"- Are recommendations actionable and relevant?\n"
|
| 548 |
+
"- Is anything important missing, vague, or invented?\n\n"
|
| 549 |
+
"Return ONLY: "
|
| 550 |
+
'{"score": <int 1-10>, "verdict": "<1-2 sentences: what was done well and what gap remains>"}'
|
| 551 |
+
),
|
| 552 |
+
expected_output='Raw JSON only: {"score": <int>, "verdict": "<string>"}. No markdown.',
|
| 553 |
+
context=[self.analyze_task, self.format_task],
|
| 554 |
+
agent=self.qa_critic,
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
def create_crew(self, data) -> str:
|
|
|
|
|
|
|
| 558 |
with _crew_lock:
|
| 559 |
return self._run_pipeline(data)
|
| 560 |
|
|
|
|
| 562 |
max_attempts = (len(_FAST_MODELS) + len(_SMART_MODELS)) * 2
|
| 563 |
|
| 564 |
for attempt in range(max_attempts):
|
|
|
|
|
|
|
| 565 |
_wait_until_available()
|
|
|
|
|
|
|
|
|
|
| 566 |
fast_model, self._fast_idx = _pick_model(_FAST_MODELS)
|
| 567 |
smart_model, self._smart_idx = _pick_model(_SMART_MODELS)
|
| 568 |
|
|
|
|
| 570 |
self.create_tasks()
|
| 571 |
|
| 572 |
crew = Crew(
|
| 573 |
+
agents=[
|
| 574 |
+
self.context_agent, self.data_cleaner, self.prompt_engineer,
|
| 575 |
+
self.data_analyst, self.output_formatter, self.qa_critic,
|
| 576 |
+
],
|
| 577 |
+
tasks=[
|
| 578 |
+
self.interpret_task, self.clean_task, self.prompt_task,
|
| 579 |
+
self.analyze_task, self.format_task, self.qa_task,
|
| 580 |
+
],
|
| 581 |
process=Process.sequential,
|
| 582 |
verbose=True,
|
| 583 |
memory=False,
|
|
|
|
| 606 |
_set_cooldown(fast_model, 60)
|
| 607 |
_set_cooldown(smart_model, 60)
|
| 608 |
print(f"[SERVER-ERR] fast={fast_model} smart={smart_model} → 60s cooldown")
|
| 609 |
+
continue
|
|
|
|
| 610 |
raise
|
| 611 |
|
| 612 |
+
fmt_task_out = crew.tasks[4].output if len(crew.tasks) > 4 else None
|
| 613 |
+
formatted = None
|
| 614 |
+
if fmt_task_out:
|
| 615 |
+
if getattr(fmt_task_out, "pydantic", None):
|
| 616 |
+
formatted = fmt_task_out.pydantic
|
| 617 |
+
else:
|
| 618 |
+
try:
|
| 619 |
+
formatted = FormattedOutput(
|
| 620 |
+
**_json.loads(_extract_json(fmt_task_out.raw or ""))
|
| 621 |
+
)
|
| 622 |
+
except Exception:
|
| 623 |
+
pass
|
| 624 |
+
|
| 625 |
+
if formatted:
|
| 626 |
+
qa_raw = result.raw if hasattr(result, "raw") else str(result)
|
| 627 |
+
try:
|
| 628 |
+
qa = _json.loads(_extract_json(qa_raw))
|
| 629 |
+
formatted.quality_score = int(qa.get("score", 0)) or None
|
| 630 |
+
formatted.quality_verdict = qa.get("verdict")
|
| 631 |
+
except Exception:
|
| 632 |
+
pass
|
| 633 |
+
return formatted.model_dump_json()
|
| 634 |
|
| 635 |
raw = result.raw if hasattr(result, "raw") else str(result)
|
| 636 |
return _extract_json(raw)
|
| 637 |
+
|
backend/calories.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server-side calorie estimation.
|
| 2 |
+
|
| 3 |
+
Keeps the watch firmware thin: the device no longer hardcodes a body weight or
|
| 4 |
+
runs MET math (the ESP32-C3 has no FPU). The watch sends duration + workout type
|
| 5 |
+
and the backend estimates the burn here, in one place.
|
| 6 |
+
|
| 7 |
+
Formula: calories ≈ MET × weight_kg × hours. MET ("metabolic equivalent") values
|
| 8 |
+
are moderate-intensity averages from the Compendium of Physical Activities.
|
| 9 |
+
"""
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
# TODO: replace with the authenticated user's real weight once a profile exists.
|
| 13 |
+
DEFAULT_WEIGHT_KG = 70.0
|
| 14 |
+
|
| 15 |
+
# MET by activity. Keys are lowercased; the watch currently reports "running".
|
| 16 |
+
_MET = {
|
| 17 |
+
"running": 9.8,
|
| 18 |
+
"run": 9.8,
|
| 19 |
+
"jogging": 7.0,
|
| 20 |
+
"walking": 3.5,
|
| 21 |
+
"walk": 3.5,
|
| 22 |
+
"hiking": 6.0,
|
| 23 |
+
"cycling": 7.5,
|
| 24 |
+
"bike": 7.5,
|
| 25 |
+
"biking": 7.5,
|
| 26 |
+
"swimming": 8.0,
|
| 27 |
+
"rowing": 7.0,
|
| 28 |
+
"elliptical": 5.0,
|
| 29 |
+
"weightlifting": 6.0,
|
| 30 |
+
"strength": 6.0,
|
| 31 |
+
"workout": 7.0,
|
| 32 |
+
}
|
| 33 |
+
_DEFAULT_MET = 7.0
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def met_for(workout_type: Optional[str]) -> float:
|
| 37 |
+
if not workout_type:
|
| 38 |
+
return _DEFAULT_MET
|
| 39 |
+
return _MET.get(workout_type.strip().lower(), _DEFAULT_MET)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def estimate_calories(duration_minutes: float,
|
| 43 |
+
workout_type: Optional[str] = None,
|
| 44 |
+
weight_kg: float = DEFAULT_WEIGHT_KG) -> float:
|
| 45 |
+
"""Estimate kcal burned for a workout, rounded to 1 decimal place."""
|
| 46 |
+
if not duration_minutes or duration_minutes <= 0:
|
| 47 |
+
return 0.0
|
| 48 |
+
return round(met_for(workout_type) * weight_kg * (duration_minutes / 60.0), 1)
|
backend/db.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import acreate_client, AsyncClient
|
| 3 |
+
|
| 4 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 5 |
+
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
|
| 6 |
+
|
| 7 |
+
_client: AsyncClient | None = None
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def get_db() -> AsyncClient:
|
| 11 |
+
global _client
|
| 12 |
+
if _client is None:
|
| 13 |
+
_client = await acreate_client(SUPABASE_URL, SUPABASE_KEY)
|
| 14 |
+
return _client
|
backend/main.py
CHANGED
|
@@ -1,201 +1,33 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
import io
|
| 3 |
-
import queue
|
| 4 |
-
import threading
|
| 5 |
-
import tempfile
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import asyncio
|
| 10 |
-
import json
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
load_dotenv()
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
-
from
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
app.add_middleware(
|
| 22 |
CORSMiddleware,
|
| 23 |
-
allow_origins=
|
|
|
|
| 24 |
allow_methods=["*"],
|
| 25 |
allow_headers=["*"],
|
| 26 |
)
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
ALLOWED_EXTS = {".csv", ".json", ".txt", ".pdf", ".xml"}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
class LineCapture(io.TextIOBase):
|
| 38 |
-
"""Captures stdout/stderr line-by-line into a queue, stripping ANSI codes."""
|
| 39 |
-
|
| 40 |
-
def __init__(self, q: queue.Queue):
|
| 41 |
-
self._q = q
|
| 42 |
-
self._buf = ""
|
| 43 |
-
|
| 44 |
-
def write(self, text: str) -> int:
|
| 45 |
-
cleaned = ANSI_ESCAPE.sub("", text)
|
| 46 |
-
cleaned = BOX_CHARS.sub("", cleaned)
|
| 47 |
-
self._buf += cleaned
|
| 48 |
-
while "\n" in self._buf:
|
| 49 |
-
line, self._buf = self._buf.split("\n", 1)
|
| 50 |
-
stripped = line.strip()
|
| 51 |
-
if stripped:
|
| 52 |
-
self._q.put(stripped)
|
| 53 |
-
return len(text)
|
| 54 |
-
|
| 55 |
-
def flush(self):
|
| 56 |
-
if self._buf.strip():
|
| 57 |
-
self._q.put(self._buf.strip())
|
| 58 |
-
self._buf = ""
|
| 59 |
-
|
| 60 |
|
| 61 |
@app.get("/health")
|
| 62 |
async def health():
|
| 63 |
return {"status": "ok"}
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
@app.post("/analyze")
|
| 67 |
-
async def analyze(context: str = Form(...), file: UploadFile = File(None)):
|
| 68 |
-
# ── Input validation (before opening the SSE stream) ──────────────────────
|
| 69 |
-
context = context.strip()
|
| 70 |
-
if not context:
|
| 71 |
-
return JSONResponse({"error": "Context is required."}, status_code=400)
|
| 72 |
-
if len(context) > MAX_CONTEXT_LEN:
|
| 73 |
-
return JSONResponse(
|
| 74 |
-
{"error": f"Context too long ({len(context)} chars, max {MAX_CONTEXT_LEN})."},
|
| 75 |
-
status_code=400,
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
file_content = None
|
| 79 |
-
file_suffix = ".csv"
|
| 80 |
-
if file and file.filename:
|
| 81 |
-
ext = os.path.splitext(file.filename)[1].lower()
|
| 82 |
-
if ext not in ALLOWED_EXTS:
|
| 83 |
-
return JSONResponse(
|
| 84 |
-
{"error": f"File type '{ext}' not supported. Allowed: {', '.join(sorted(ALLOWED_EXTS))}"},
|
| 85 |
-
status_code=400,
|
| 86 |
-
)
|
| 87 |
-
file_content = await file.read()
|
| 88 |
-
if len(file_content) > MAX_FILE_SIZE:
|
| 89 |
-
return JSONResponse(
|
| 90 |
-
{"error": f"File too large ({len(file_content) // 1024}KB, max {MAX_FILE_SIZE // 1024 // 1024}MB)."},
|
| 91 |
-
status_code=400,
|
| 92 |
-
)
|
| 93 |
-
file_suffix = ext
|
| 94 |
-
|
| 95 |
-
# ── SSE stream ────────────────────────────────────────────────────────────
|
| 96 |
-
async def event_stream():
|
| 97 |
-
q: queue.Queue = queue.Queue()
|
| 98 |
-
|
| 99 |
-
def run_crew():
|
| 100 |
-
old_stdout, old_stderr = sys.stdout, sys.stderr
|
| 101 |
-
capture = LineCapture(q)
|
| 102 |
-
sys.stdout = capture
|
| 103 |
-
sys.stderr = capture
|
| 104 |
-
data_path = None
|
| 105 |
-
try:
|
| 106 |
-
if file_content:
|
| 107 |
-
with tempfile.NamedTemporaryFile(
|
| 108 |
-
delete=False, suffix=file_suffix
|
| 109 |
-
) as tmp:
|
| 110 |
-
tmp.write(file_content)
|
| 111 |
-
data_path = tmp.name
|
| 112 |
-
|
| 113 |
-
bots = Bots(context)
|
| 114 |
-
result = bots.create_crew(data_path or "")
|
| 115 |
-
if result:
|
| 116 |
-
q.put({"__result__": result})
|
| 117 |
-
except Exception as exc:
|
| 118 |
-
# Flush any buffered partial output before the error
|
| 119 |
-
capture.flush()
|
| 120 |
-
import traceback
|
| 121 |
-
q.put(f"[ERROR] {exc}")
|
| 122 |
-
for line in traceback.format_exc().splitlines():
|
| 123 |
-
if line.strip():
|
| 124 |
-
q.put(f"[TRACE] {line}")
|
| 125 |
-
finally:
|
| 126 |
-
sys.stdout = old_stdout
|
| 127 |
-
sys.stderr = old_stderr
|
| 128 |
-
if data_path and os.path.exists(data_path):
|
| 129 |
-
os.unlink(data_path)
|
| 130 |
-
q.put(None)
|
| 131 |
-
|
| 132 |
-
thread = threading.Thread(target=run_crew, daemon=True)
|
| 133 |
-
thread.start()
|
| 134 |
-
|
| 135 |
-
# CrewAI internal noise that clutters the stream during rotation/retry.
|
| 136 |
-
# We surface our own [ROTATE] / [RETRY] lines instead.
|
| 137 |
-
_NOISE = (
|
| 138 |
-
"ERROR:root:",
|
| 139 |
-
"ERROR:crewai.",
|
| 140 |
-
"[CrewAIEventsBus]",
|
| 141 |
-
"An unknown error occurred. Please check",
|
| 142 |
-
"Error details: Error code:",
|
| 143 |
-
"Error details: Model ",
|
| 144 |
-
# CrewAI event ordering warnings (box chars are stripped in LineCapture)
|
| 145 |
-
"'agent_execution_started'",
|
| 146 |
-
"Tracing Preference Saved",
|
| 147 |
-
"Tracing has been disabled",
|
| 148 |
-
"Your preference has been saved",
|
| 149 |
-
"To enable tracing later",
|
| 150 |
-
"Set tracing=True",
|
| 151 |
-
"Set CREWAI_TRACING_ENABLED",
|
| 152 |
-
"Run: crewai traces",
|
| 153 |
-
"[Finalize]",
|
| 154 |
-
"[TRACE]",
|
| 155 |
-
"✨ Update Available",
|
| 156 |
-
"collect traces.",
|
| 157 |
-
"New version of crewai",
|
| 158 |
-
"Run `pip install",
|
| 159 |
-
"pip install --upgrade",
|
| 160 |
-
"All providers rate-limited", # litellm internal retry message
|
| 161 |
-
"Auto-retrying in", # litellm internal retry message
|
| 162 |
-
"[RETRY]", # litellm router retry prefix
|
| 163 |
-
)
|
| 164 |
-
|
| 165 |
-
loop = asyncio.get_running_loop()
|
| 166 |
-
deadline = time.monotonic() + MAX_RUNTIME
|
| 167 |
-
|
| 168 |
-
while True:
|
| 169 |
-
# Overall pipeline timeout guard
|
| 170 |
-
remaining = deadline - time.monotonic()
|
| 171 |
-
if remaining <= 0:
|
| 172 |
-
yield f"data: {json.dumps('[ERROR] Analysis timed out after 10 minutes.')}\n\n"
|
| 173 |
-
yield f"data: {json.dumps('__DONE__')}\n\n"
|
| 174 |
-
break
|
| 175 |
-
|
| 176 |
-
try:
|
| 177 |
-
item = await loop.run_in_executor(
|
| 178 |
-
None, lambda: q.get(timeout=min(30, remaining))
|
| 179 |
-
)
|
| 180 |
-
except queue.Empty:
|
| 181 |
-
# Heartbeat keeps Cloudflare proxy from closing idle connection
|
| 182 |
-
yield ": ping\n\n"
|
| 183 |
-
continue
|
| 184 |
-
|
| 185 |
-
# Drop internal CrewAI error noise during rotation; keep our own messages
|
| 186 |
-
if isinstance(item, str) and any(item.startswith(p) or p in item for p in _NOISE):
|
| 187 |
-
continue
|
| 188 |
-
|
| 189 |
-
if item is None:
|
| 190 |
-
yield f"data: {json.dumps('__DONE__')}\n\n"
|
| 191 |
-
break
|
| 192 |
-
if isinstance(item, dict) and "__result__" in item:
|
| 193 |
-
yield f"data: {json.dumps({'type': 'result', 'content': item['__result__']})}\n\n"
|
| 194 |
-
else:
|
| 195 |
-
yield f"data: {json.dumps(item)}\n\n"
|
| 196 |
-
|
| 197 |
-
return StreamingResponse(
|
| 198 |
-
event_stream(),
|
| 199 |
-
media_type="text/event-stream",
|
| 200 |
-
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 201 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from dotenv import load_dotenv
|
| 2 |
load_dotenv()
|
| 3 |
|
| 4 |
+
import os
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from routes import watch, analysis, user, charts, gps, integrations
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="Fitness AI Agents")
|
| 10 |
|
| 11 |
+
_ALLOWED_ORIGINS = [o.strip() for o in os.getenv(
|
| 12 |
+
"ALLOWED_ORIGINS",
|
| 13 |
+
"http://localhost:5173,http://localhost:4173",
|
| 14 |
+
).split(",") if o.strip()]
|
| 15 |
|
| 16 |
app.add_middleware(
|
| 17 |
CORSMiddleware,
|
| 18 |
+
allow_origins=_ALLOWED_ORIGINS,
|
| 19 |
+
allow_credentials=True,
|
| 20 |
allow_methods=["*"],
|
| 21 |
allow_headers=["*"],
|
| 22 |
)
|
| 23 |
|
| 24 |
+
app.include_router(watch.router, prefix="/watch", tags=["Watch"])
|
| 25 |
+
app.include_router(analysis.router, prefix="/analyze", tags=["Analysis"])
|
| 26 |
+
app.include_router(user.router, prefix="/user", tags=["User"])
|
| 27 |
+
app.include_router(charts.router, prefix="/charts", tags=["Charts"])
|
| 28 |
+
app.include_router(gps.router, prefix="/routes", tags=["GPS"])
|
| 29 |
+
app.include_router(integrations.router, prefix="/integrations", tags=["Integrations"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
@app.get("/health")
|
| 32 |
async def health():
|
| 33 |
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/models/__init__.py
ADDED
|
File without changes
|
backend/models/analysis.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AnalysisRequest(BaseModel):
|
| 7 |
+
context: str # what the user wants to know
|
| 8 |
+
date_from: Optional[datetime] = None
|
| 9 |
+
date_to: Optional[datetime] = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AnalysisResult(BaseModel):
|
| 13 |
+
user_id: str
|
| 14 |
+
context: str
|
| 15 |
+
summary: str
|
| 16 |
+
key_findings: list[str]
|
| 17 |
+
anomalies: list[str]
|
| 18 |
+
recommendations: list[str]
|
| 19 |
+
created_at: datetime = datetime.utcnow()
|
backend/models/watch.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class WatchReading(BaseModel):
|
| 7 |
+
timestamp: datetime
|
| 8 |
+
heart_rate: Optional[float] = None
|
| 9 |
+
steps: Optional[int] = None
|
| 10 |
+
calories_burned: Optional[float] = None
|
| 11 |
+
active_calories: Optional[float] = None
|
| 12 |
+
distance_meters: Optional[float] = None
|
| 13 |
+
sleep_hours: Optional[float] = None
|
| 14 |
+
spo2: Optional[float] = None # blood oxygen %
|
| 15 |
+
hrv: Optional[float] = None # heart rate variability
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class WorkoutSession(BaseModel):
|
| 19 |
+
timestamp: datetime
|
| 20 |
+
workout_type: str # running, cycling, weightlifting, etc.
|
| 21 |
+
duration_minutes: float
|
| 22 |
+
avg_heart_rate: Optional[float] = None
|
| 23 |
+
max_heart_rate: Optional[float] = None
|
| 24 |
+
ending_heart_rate: Optional[float] = None # BPM at end/cool-down
|
| 25 |
+
calories_burned: Optional[float] = None
|
| 26 |
+
distance_meters: Optional[float] = None
|
| 27 |
+
notes: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class WatchSyncPayload(BaseModel):
|
| 31 |
+
readings: list[WatchReading] = []
|
| 32 |
+
workouts: list[WorkoutSession] = []
|
| 33 |
+
device: Optional[str] = None # "apple_watch", "garmin", "fitbit", etc.
|
| 34 |
+
app_version: Optional[str] = None
|
backend/requirements.txt
CHANGED
|
@@ -1,7 +1,11 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn
|
| 3 |
-
python-dotenv
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.136.1
|
| 2 |
+
uvicorn==0.47.0
|
| 3 |
+
python-dotenv==1.2.2
|
| 4 |
+
python-multipart==0.0.20
|
| 5 |
+
httpx==0.28.1
|
| 6 |
+
PyJWT==2.12.1
|
| 7 |
+
supabase==2.30.1
|
| 8 |
+
crewai==1.14.5
|
| 9 |
+
crewai-tools==1.14.5
|
| 10 |
+
pandas==3.0.3
|
| 11 |
+
fastembed==0.8.0
|
backend/routes/__init__.py
ADDED
|
File without changes
|
backend/routes/analysis.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import csv
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from auth import get_user_id
|
| 9 |
+
from db import get_db
|
| 10 |
+
from models.analysis import AnalysisRequest
|
| 11 |
+
from bots import Bots
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
# Columns from the routes table that are meaningful for analysis.
|
| 16 |
+
# Excludes coordinates (raw lat/lng blob), user_id, and id.
|
| 17 |
+
_ROUTE_COLUMNS = [
|
| 18 |
+
"started_at", "ended_at", "workout_type",
|
| 19 |
+
"distance_meters", "duration_seconds", "pace", "calories_burned", "notes",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _write_csv(rows: list[dict], columns: list[str]) -> str:
|
| 24 |
+
"""Write rows to a temp CSV (only the given columns) and return the path."""
|
| 25 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="") as f:
|
| 26 |
+
writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore")
|
| 27 |
+
writer.writeheader()
|
| 28 |
+
writer.writerows(rows)
|
| 29 |
+
return f.name
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/")
|
| 33 |
+
async def run_analysis(request: AnalysisRequest, user_id: str = Depends(get_user_id)):
|
| 34 |
+
"""Pull the user's watch data + routes, run the AI crew, store and return the result."""
|
| 35 |
+
db = await get_db()
|
| 36 |
+
|
| 37 |
+
# ── Watch readings + workouts ──────────────────────────────────────────
|
| 38 |
+
wd_query = db.table("watch_data").select("*").eq("user_id", user_id)
|
| 39 |
+
if request.date_from:
|
| 40 |
+
wd_query = wd_query.gte("timestamp", request.date_from.isoformat())
|
| 41 |
+
if request.date_to:
|
| 42 |
+
wd_query = wd_query.lte("timestamp", request.date_to.isoformat())
|
| 43 |
+
|
| 44 |
+
wd_result = await wd_query.order("timestamp").execute()
|
| 45 |
+
wd_rows = wd_result.data
|
| 46 |
+
|
| 47 |
+
if not wd_rows:
|
| 48 |
+
raise HTTPException(status_code=404, detail="No watch data found for this user")
|
| 49 |
+
|
| 50 |
+
for row in wd_rows:
|
| 51 |
+
row.pop("user_id", None)
|
| 52 |
+
|
| 53 |
+
# ── GPS routes ─────────────────────────────────────────────────────────
|
| 54 |
+
rt_query = db.table("routes").select(", ".join(_ROUTE_COLUMNS)).eq("user_id", user_id)
|
| 55 |
+
if request.date_from:
|
| 56 |
+
rt_query = rt_query.gte("started_at", request.date_from.isoformat())
|
| 57 |
+
if request.date_to:
|
| 58 |
+
rt_query = rt_query.lte("started_at", request.date_to.isoformat())
|
| 59 |
+
|
| 60 |
+
rt_result = await rt_query.order("started_at").execute()
|
| 61 |
+
rt_rows = rt_result.data
|
| 62 |
+
|
| 63 |
+
# ── Write CSVs ─────────────────────────────────────────────────────────
|
| 64 |
+
tmp_watch = _write_csv(wd_rows, list(wd_rows[0].keys()))
|
| 65 |
+
tmp_routes = _write_csv(rt_rows, _ROUTE_COLUMNS) if rt_rows else None
|
| 66 |
+
|
| 67 |
+
# Pass both paths (newline-separated) — the crew's task descriptions already
|
| 68 |
+
# handle multiple files and say "If multiple files, analyze together."
|
| 69 |
+
data_input = tmp_watch
|
| 70 |
+
if tmp_routes:
|
| 71 |
+
data_input = f"{tmp_watch}\n{tmp_routes}"
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
bots = Bots(context=request.context)
|
| 75 |
+
loop = asyncio.get_running_loop()
|
| 76 |
+
raw_json = await loop.run_in_executor(None, lambda: bots.create_crew(data=data_input))
|
| 77 |
+
except Exception as e:
|
| 78 |
+
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
| 79 |
+
finally:
|
| 80 |
+
if os.path.exists(tmp_watch):
|
| 81 |
+
os.unlink(tmp_watch)
|
| 82 |
+
if tmp_routes and os.path.exists(tmp_routes):
|
| 83 |
+
os.unlink(tmp_routes)
|
| 84 |
+
|
| 85 |
+
# Parse the structured FormattedOutput returned by bots.create_crew()
|
| 86 |
+
parsed: dict = {}
|
| 87 |
+
try:
|
| 88 |
+
parsed = json.loads(raw_json)
|
| 89 |
+
except Exception:
|
| 90 |
+
parsed = {}
|
| 91 |
+
|
| 92 |
+
record = {
|
| 93 |
+
"user_id": user_id,
|
| 94 |
+
"context": request.context,
|
| 95 |
+
"summary": parsed.get("summary") or raw_json,
|
| 96 |
+
"key_findings": parsed.get("findings") or [],
|
| 97 |
+
"recommendations": parsed.get("recommendations") or [],
|
| 98 |
+
"output_type": parsed.get("output_type"),
|
| 99 |
+
"chart_type": parsed.get("chart_type"),
|
| 100 |
+
"chart_title": parsed.get("chart_title"),
|
| 101 |
+
"data_points": parsed.get("data_points"),
|
| 102 |
+
"metrics": parsed.get("metrics"),
|
| 103 |
+
"table_headers": parsed.get("table_headers"),
|
| 104 |
+
"table_rows": parsed.get("table_rows"),
|
| 105 |
+
"quality_score": parsed.get("quality_score"),
|
| 106 |
+
"quality_verdict": parsed.get("quality_verdict"),
|
| 107 |
+
"raw_output": parsed or None, # full payload — lets frontend render comparison/heatmap/code
|
| 108 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
# output_type must always be present so the frontend can pick the right renderer
|
| 112 |
+
if not record.get("output_type"):
|
| 113 |
+
record["output_type"] = "report"
|
| 114 |
+
|
| 115 |
+
# Strip remaining None values so Supabase doesn't complain about unknown columns
|
| 116 |
+
record = {k: v for k, v in record.items() if v is not None}
|
| 117 |
+
|
| 118 |
+
await db.table("analyses").insert(record).execute()
|
| 119 |
+
return record
|
backend/routes/charts.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from auth import get_user_id
|
| 3 |
+
from db import get_db
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def date_key(iso: str) -> str:
|
| 11 |
+
return iso[:10] if iso else ""
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/")
|
| 15 |
+
async def get_chart_data(user_id: str = Depends(get_user_id)):
|
| 16 |
+
db = await get_db()
|
| 17 |
+
|
| 18 |
+
workouts_res = await (
|
| 19 |
+
db.table("watch_data")
|
| 20 |
+
.select("*")
|
| 21 |
+
.eq("user_id", user_id)
|
| 22 |
+
.eq("type", "workout")
|
| 23 |
+
.order("timestamp")
|
| 24 |
+
.execute()
|
| 25 |
+
)
|
| 26 |
+
readings_res = await (
|
| 27 |
+
db.table("watch_data")
|
| 28 |
+
.select("timestamp,heart_rate,steps,calories_burned,sleep_hours,spo2,hrv")
|
| 29 |
+
.eq("user_id", user_id)
|
| 30 |
+
.eq("type", "reading")
|
| 31 |
+
.order("timestamp")
|
| 32 |
+
.execute()
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
workouts = workouts_res.data or []
|
| 36 |
+
readings = readings_res.data or []
|
| 37 |
+
|
| 38 |
+
# calories burned per day (workouts)
|
| 39 |
+
cal_by_day: dict = defaultdict(float)
|
| 40 |
+
for w in workouts:
|
| 41 |
+
if w.get("calories_burned"):
|
| 42 |
+
cal_by_day[date_key(w["timestamp"])] += w["calories_burned"]
|
| 43 |
+
|
| 44 |
+
# avg heart rate per day (readings)
|
| 45 |
+
hr_by_day: dict = defaultdict(list)
|
| 46 |
+
for r in readings:
|
| 47 |
+
if r.get("heart_rate"):
|
| 48 |
+
hr_by_day[date_key(r["timestamp"])].append(r["heart_rate"])
|
| 49 |
+
hr_avg_by_day = {d: round(sum(v) / len(v), 1) for d, v in hr_by_day.items()}
|
| 50 |
+
|
| 51 |
+
# steps per day
|
| 52 |
+
steps_by_day: dict = defaultdict(int)
|
| 53 |
+
for r in readings:
|
| 54 |
+
if r.get("steps"):
|
| 55 |
+
steps_by_day[date_key(r["timestamp"])] += r["steps"]
|
| 56 |
+
|
| 57 |
+
# sleep hours per day
|
| 58 |
+
sleep_by_day: dict = {}
|
| 59 |
+
for r in readings:
|
| 60 |
+
if r.get("sleep_hours"):
|
| 61 |
+
sleep_by_day[date_key(r["timestamp"])] = r["sleep_hours"]
|
| 62 |
+
|
| 63 |
+
# workout type breakdown
|
| 64 |
+
type_counts: dict = defaultdict(int)
|
| 65 |
+
for w in workouts:
|
| 66 |
+
if w.get("workout_type"):
|
| 67 |
+
type_counts[w["workout_type"]] += 1
|
| 68 |
+
|
| 69 |
+
# recent workouts list (last 10)
|
| 70 |
+
recent = sorted(workouts, key=lambda x: x.get("timestamp", ""), reverse=True)[:10]
|
| 71 |
+
|
| 72 |
+
return {
|
| 73 |
+
"calories": {
|
| 74 |
+
"labels": sorted(cal_by_day.keys()),
|
| 75 |
+
"values": [cal_by_day[d] for d in sorted(cal_by_day.keys())],
|
| 76 |
+
},
|
| 77 |
+
"heart_rate": {
|
| 78 |
+
"labels": sorted(hr_avg_by_day.keys()),
|
| 79 |
+
"values": [hr_avg_by_day[d] for d in sorted(hr_avg_by_day.keys())],
|
| 80 |
+
},
|
| 81 |
+
"steps": {
|
| 82 |
+
"labels": sorted(steps_by_day.keys()),
|
| 83 |
+
"values": [steps_by_day[d] for d in sorted(steps_by_day.keys())],
|
| 84 |
+
},
|
| 85 |
+
"sleep": {
|
| 86 |
+
"labels": sorted(sleep_by_day.keys()),
|
| 87 |
+
"values": [sleep_by_day[d] for d in sorted(sleep_by_day.keys())],
|
| 88 |
+
},
|
| 89 |
+
"workout_types": {
|
| 90 |
+
"labels": list(type_counts.keys()),
|
| 91 |
+
"values": list(type_counts.values()),
|
| 92 |
+
},
|
| 93 |
+
"recent_workouts": recent,
|
| 94 |
+
}
|
backend/routes/gps.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from auth import get_user_id
|
| 6 |
+
from db import get_db
|
| 7 |
+
from calories import estimate_calories
|
| 8 |
+
import math
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Coordinate(BaseModel):
|
| 14 |
+
lat: float
|
| 15 |
+
lng: float
|
| 16 |
+
timestamp: str
|
| 17 |
+
altitude: Optional[float] = None
|
| 18 |
+
speed: Optional[float] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class RoutePayload(BaseModel):
|
| 22 |
+
workout_type: str
|
| 23 |
+
coordinates: list[Coordinate]
|
| 24 |
+
started_at: str
|
| 25 |
+
ended_at: str
|
| 26 |
+
# Sent by the watch so distance/pace/calories are all computed here, not on
|
| 27 |
+
# the device.
|
| 28 |
+
avg_heart_rate: Optional[float] = None
|
| 29 |
+
max_heart_rate: Optional[float] = None
|
| 30 |
+
# True when the client posts ONLY a route (no separate workout) — the watch
|
| 31 |
+
# and the web GPS tracker. Tells us to mirror a workout row into watch_data.
|
| 32 |
+
# Clients that post their own workout (manual entry, Strava/Nike/Garmin
|
| 33 |
+
# imports) leave this false so the workout isn't counted twice.
|
| 34 |
+
record_workout: bool = False
|
| 35 |
+
notes: Optional[str] = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def haversine(lat1, lng1, lat2, lng2) -> float:
|
| 39 |
+
R = 6371000
|
| 40 |
+
p = math.pi / 180
|
| 41 |
+
a = (math.sin((lat2 - lat1) * p / 2) ** 2 +
|
| 42 |
+
math.cos(lat1 * p) * math.cos(lat2 * p) *
|
| 43 |
+
math.sin((lng2 - lng1) * p / 2) ** 2)
|
| 44 |
+
return 2 * R * math.asin(math.sqrt(a))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def calc_distance(coords: list[Coordinate]) -> float:
|
| 48 |
+
total = 0.0
|
| 49 |
+
for i in range(1, len(coords)):
|
| 50 |
+
total += haversine(coords[i-1].lat, coords[i-1].lng, coords[i].lat, coords[i].lng)
|
| 51 |
+
return round(total, 1)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def calc_duration(started_at: str, ended_at: str) -> int:
|
| 55 |
+
try:
|
| 56 |
+
start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
| 57 |
+
end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
|
| 58 |
+
return max(0, int((end - start).total_seconds()))
|
| 59 |
+
except Exception:
|
| 60 |
+
return 0
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def calc_pace(distance_m: float, duration_s: int) -> Optional[str]:
|
| 64 |
+
if distance_m < 1 or duration_s < 1:
|
| 65 |
+
return None
|
| 66 |
+
mins_per_km = (duration_s / 60) / (distance_m / 1000)
|
| 67 |
+
m = int(mins_per_km)
|
| 68 |
+
s = int((mins_per_km - m) * 60)
|
| 69 |
+
return f"{m}:{s:02d} /km"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@router.post("/")
|
| 73 |
+
async def save_route(payload: RoutePayload, user_id: str = Depends(get_user_id)):
|
| 74 |
+
"""Save a completed GPS route."""
|
| 75 |
+
if len(payload.coordinates) < 2:
|
| 76 |
+
raise HTTPException(status_code=400, detail="Route needs at least 2 coordinates")
|
| 77 |
+
|
| 78 |
+
db = await get_db()
|
| 79 |
+
distance = calc_distance(payload.coordinates)
|
| 80 |
+
duration = calc_duration(payload.started_at, payload.ended_at)
|
| 81 |
+
pace = calc_pace(distance, duration)
|
| 82 |
+
duration_min = round(duration / 60.0, 2)
|
| 83 |
+
|
| 84 |
+
calories = estimate_calories(duration_min, payload.workout_type)
|
| 85 |
+
|
| 86 |
+
record = {
|
| 87 |
+
"user_id": user_id,
|
| 88 |
+
"workout_type": payload.workout_type,
|
| 89 |
+
"coordinates": [c.model_dump() for c in payload.coordinates],
|
| 90 |
+
"distance_meters": distance,
|
| 91 |
+
"duration_seconds": duration,
|
| 92 |
+
"pace": pace,
|
| 93 |
+
"calories_burned": calories,
|
| 94 |
+
"started_at": payload.started_at,
|
| 95 |
+
"ended_at": payload.ended_at,
|
| 96 |
+
"notes": payload.notes,
|
| 97 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
result = await db.table("routes").insert(record).execute()
|
| 101 |
+
if not result.data:
|
| 102 |
+
raise HTTPException(status_code=500, detail="Route insert failed")
|
| 103 |
+
|
| 104 |
+
# Route-only clients (the watch, the web GPS tracker) ask us to also record
|
| 105 |
+
# the workout — distance, pace and calories all computed here, server-side,
|
| 106 |
+
# so the device doesn't have to. Clients that post their own workout leave
|
| 107 |
+
# record_workout false to avoid double-counting.
|
| 108 |
+
if payload.record_workout:
|
| 109 |
+
workout_row = {
|
| 110 |
+
"user_id": user_id,
|
| 111 |
+
"type": "workout",
|
| 112 |
+
"device": "gps_route",
|
| 113 |
+
"timestamp": payload.started_at,
|
| 114 |
+
"workout_type": payload.workout_type,
|
| 115 |
+
"duration_minutes": duration_min,
|
| 116 |
+
"avg_heart_rate": payload.avg_heart_rate,
|
| 117 |
+
"max_heart_rate": payload.max_heart_rate,
|
| 118 |
+
"calories_burned": calories,
|
| 119 |
+
"distance_meters": distance,
|
| 120 |
+
}
|
| 121 |
+
try:
|
| 122 |
+
await db.table("watch_data").insert(workout_row).execute()
|
| 123 |
+
except Exception as e:
|
| 124 |
+
# The route saved fine; don't fail the request if the mirror does.
|
| 125 |
+
print(f"[routes] workout mirror insert failed: {e}")
|
| 126 |
+
|
| 127 |
+
return result.data[0]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@router.get("/")
|
| 131 |
+
async def get_routes(user_id: str = Depends(get_user_id), limit: int = 20):
|
| 132 |
+
"""Fetch the user's past routes, newest first."""
|
| 133 |
+
db = await get_db()
|
| 134 |
+
result = await (
|
| 135 |
+
db.table("routes")
|
| 136 |
+
.select("*")
|
| 137 |
+
.eq("user_id", user_id)
|
| 138 |
+
.order("started_at", desc=True)
|
| 139 |
+
.limit(limit)
|
| 140 |
+
.execute()
|
| 141 |
+
)
|
| 142 |
+
return {"routes": result.data}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.get("/{route_id}")
|
| 146 |
+
async def get_route(route_id: str, user_id: str = Depends(get_user_id)):
|
| 147 |
+
"""Fetch a single route by ID."""
|
| 148 |
+
db = await get_db()
|
| 149 |
+
result = await (
|
| 150 |
+
db.table("routes")
|
| 151 |
+
.select("*")
|
| 152 |
+
.eq("id", route_id)
|
| 153 |
+
.eq("user_id", user_id)
|
| 154 |
+
.single()
|
| 155 |
+
.execute()
|
| 156 |
+
)
|
| 157 |
+
if not result.data:
|
| 158 |
+
raise HTTPException(status_code=404, detail="Route not found")
|
| 159 |
+
return result.data
|
backend/routes/integrations.py
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import csv
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
import math
|
| 8 |
+
import os
|
| 9 |
+
import urllib.parse
|
| 10 |
+
import xml.etree.ElementTree as ET
|
| 11 |
+
import zipfile
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 15 |
+
from fastapi.responses import RedirectResponse
|
| 16 |
+
from auth import get_user_id
|
| 17 |
+
from db import get_db
|
| 18 |
+
from datetime import datetime, timezone, timedelta
|
| 19 |
+
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
STRAVA_CLIENT_ID = os.getenv("STRAVA_CLIENT_ID", "")
|
| 23 |
+
STRAVA_CLIENT_SECRET = os.getenv("STRAVA_CLIENT_SECRET", "")
|
| 24 |
+
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
|
| 25 |
+
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
| 26 |
+
|
| 27 |
+
_OAUTH_SECRET = os.getenv("OAUTH_STATE_SECRET", os.urandom(32).hex())
|
| 28 |
+
|
| 29 |
+
STRAVA_AUTH_URL = "https://www.strava.com/oauth/authorize"
|
| 30 |
+
STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
|
| 31 |
+
STRAVA_ACTIVITIES = "https://www.strava.com/api/v3/athlete/activities"
|
| 32 |
+
STRAVA_STREAMS_URL = "https://www.strava.com/api/v3/activities/{id}/streams"
|
| 33 |
+
|
| 34 |
+
NRC_TYPE_MAP = {
|
| 35 |
+
"nike.run.gps": "running",
|
| 36 |
+
"nike.run.manual": "running",
|
| 37 |
+
"nike.run.treadmill": "running",
|
| 38 |
+
"nike.sport.walk": "walking",
|
| 39 |
+
"nike.sport.hike": "hiking",
|
| 40 |
+
"nike.sport.cycle": "cycling",
|
| 41 |
+
"nike.sport.swim": "swimming",
|
| 42 |
+
"run": "running",
|
| 43 |
+
"walk": "walking",
|
| 44 |
+
"hike": "hiking",
|
| 45 |
+
"cycle": "cycling",
|
| 46 |
+
"swim": "swimming",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
STRAVA_TYPE_MAP = {
|
| 50 |
+
"Run": "running", "VirtualRun": "running",
|
| 51 |
+
"Ride": "cycling", "VirtualRide": "cycling", "EBikeRide": "cycling",
|
| 52 |
+
"Walk": "walking",
|
| 53 |
+
"Hike": "hiking",
|
| 54 |
+
"WeightTraining": "weightlifting",
|
| 55 |
+
"Swim": "swimming",
|
| 56 |
+
"Workout": "other", "Crossfit": "other", "Elliptical": "other",
|
| 57 |
+
"RockClimbing": "hiking", "Yoga": "other", "Pilates": "other",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/status")
|
| 62 |
+
async def integration_status(user_id: str = Depends(get_user_id)):
|
| 63 |
+
db = await get_db()
|
| 64 |
+
result = await (
|
| 65 |
+
db.table("user_integrations")
|
| 66 |
+
.select("provider")
|
| 67 |
+
.eq("user_id", user_id)
|
| 68 |
+
.execute()
|
| 69 |
+
)
|
| 70 |
+
return {row["provider"]: True for row in (result.data or [])}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _make_state(user_id: str) -> str:
|
| 74 |
+
sig = hmac.new(_OAUTH_SECRET.encode(), user_id.encode(), hashlib.sha256).hexdigest()
|
| 75 |
+
return f"{user_id}.{sig}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _verify_state(state: str) -> str | None:
|
| 79 |
+
"""Return user_id if state is valid, else None."""
|
| 80 |
+
try:
|
| 81 |
+
user_id, sig = state.rsplit(".", 1)
|
| 82 |
+
except ValueError:
|
| 83 |
+
return None
|
| 84 |
+
expected = hmac.new(_OAUTH_SECRET.encode(), user_id.encode(), hashlib.sha256).hexdigest()
|
| 85 |
+
if hmac.compare_digest(sig, expected):
|
| 86 |
+
return user_id
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/strava/connect")
|
| 91 |
+
async def strava_connect(user_id: str = Depends(get_user_id)):
|
| 92 |
+
callback = f"{BACKEND_URL}/integrations/strava/callback"
|
| 93 |
+
url = (
|
| 94 |
+
f"{STRAVA_AUTH_URL}"
|
| 95 |
+
f"?client_id={STRAVA_CLIENT_ID}"
|
| 96 |
+
f"&redirect_uri={callback}"
|
| 97 |
+
f"&response_type=code"
|
| 98 |
+
f"&scope=activity:read_all"
|
| 99 |
+
f"&state={_make_state(user_id)}"
|
| 100 |
+
f"&approval_prompt=auto"
|
| 101 |
+
)
|
| 102 |
+
return {"url": url}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.get("/strava/callback")
|
| 106 |
+
async def strava_callback(code: str = "", state: str = "", error: str = ""):
|
| 107 |
+
if error or not code:
|
| 108 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?error={error or 'access_denied'}")
|
| 109 |
+
|
| 110 |
+
user_id = _verify_state(state)
|
| 111 |
+
if not user_id:
|
| 112 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?error=invalid_state")
|
| 113 |
+
async with httpx.AsyncClient() as client:
|
| 114 |
+
resp = await client.post(STRAVA_TOKEN_URL, data={
|
| 115 |
+
"client_id": STRAVA_CLIENT_ID,
|
| 116 |
+
"client_secret": STRAVA_CLIENT_SECRET,
|
| 117 |
+
"code": code,
|
| 118 |
+
"grant_type": "authorization_code",
|
| 119 |
+
})
|
| 120 |
+
if resp.status_code != 200:
|
| 121 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?error=token_exchange_failed")
|
| 122 |
+
data = resp.json()
|
| 123 |
+
|
| 124 |
+
db = await get_db()
|
| 125 |
+
row = {
|
| 126 |
+
"user_id": user_id,
|
| 127 |
+
"provider": "strava",
|
| 128 |
+
"access_token": data["access_token"],
|
| 129 |
+
"refresh_token": data.get("refresh_token"),
|
| 130 |
+
"expires_at": datetime.fromtimestamp(data["expires_at"], tz=timezone.utc).isoformat(),
|
| 131 |
+
"athlete_id": str(data.get("athlete", {}).get("id", "")),
|
| 132 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 133 |
+
}
|
| 134 |
+
await db.table("user_integrations").upsert(row, on_conflict="user_id,provider").execute()
|
| 135 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?connected=strava")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.post("/strava/sync")
|
| 139 |
+
async def strava_sync(user_id: str = Depends(get_user_id)):
|
| 140 |
+
"""Fetch latest Strava activities. Saves workouts to watch_data and GPS routes to routes table."""
|
| 141 |
+
db = await get_db()
|
| 142 |
+
|
| 143 |
+
integration = await (
|
| 144 |
+
db.table("user_integrations")
|
| 145 |
+
.select("*")
|
| 146 |
+
.eq("user_id", user_id)
|
| 147 |
+
.eq("provider", "strava")
|
| 148 |
+
.single()
|
| 149 |
+
.execute()
|
| 150 |
+
)
|
| 151 |
+
if not integration.data:
|
| 152 |
+
return {"synced": 0, "routes_saved": 0, "error": "Strava not connected"}
|
| 153 |
+
|
| 154 |
+
token = await _refresh_strava_token_if_needed(db, integration.data)
|
| 155 |
+
|
| 156 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 157 |
+
resp = await client.get(
|
| 158 |
+
STRAVA_ACTIVITIES,
|
| 159 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 160 |
+
params={"per_page": 30},
|
| 161 |
+
)
|
| 162 |
+
if resp.status_code != 200:
|
| 163 |
+
return {"synced": 0, "routes_saved": 0, "error": "Failed to fetch Strava activities"}
|
| 164 |
+
activities = resp.json()
|
| 165 |
+
|
| 166 |
+
workout_rows = []
|
| 167 |
+
route_rows = []
|
| 168 |
+
|
| 169 |
+
for a in activities:
|
| 170 |
+
start_date = a.get("start_date", "")
|
| 171 |
+
if not start_date:
|
| 172 |
+
continue # skip activities without a date — we can't place them in time
|
| 173 |
+
workout_type = STRAVA_TYPE_MAP.get(a.get("type", ""), "other")
|
| 174 |
+
|
| 175 |
+
# ── Workout record ──────────────────────────────────────────
|
| 176 |
+
workout_rows.append({
|
| 177 |
+
"user_id": user_id,
|
| 178 |
+
"type": "workout",
|
| 179 |
+
"device": "strava",
|
| 180 |
+
"timestamp": start_date,
|
| 181 |
+
"workout_type": workout_type,
|
| 182 |
+
"duration_minutes": round(a.get("moving_time", 0) / 60, 1),
|
| 183 |
+
"distance_meters": a.get("distance"),
|
| 184 |
+
"calories_burned": a.get("calories"),
|
| 185 |
+
"avg_heart_rate": a.get("average_heartrate"),
|
| 186 |
+
"max_heart_rate": a.get("max_heartrate"),
|
| 187 |
+
"ending_heart_rate": None, # not in activity summary; streams would be needed
|
| 188 |
+
"notes": a.get("name"),
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
# ── GPS route — only for activities that have a map ─────────
|
| 192 |
+
if a.get("map", {}).get("summary_polyline"):
|
| 193 |
+
streams_resp = await client.get(
|
| 194 |
+
STRAVA_STREAMS_URL.format(id=a["id"]),
|
| 195 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 196 |
+
params={"keys": "latlng,time,heartrate", "key_by_type": "true"},
|
| 197 |
+
)
|
| 198 |
+
if streams_resp.status_code == 200:
|
| 199 |
+
streams = streams_resp.json()
|
| 200 |
+
latlng = streams.get("latlng", {}).get("data", [])
|
| 201 |
+
times = streams.get("time", {}).get("data", [])
|
| 202 |
+
hr_stream = streams.get("heartrate", {}).get("data", [])
|
| 203 |
+
|
| 204 |
+
if len(latlng) >= 2:
|
| 205 |
+
try:
|
| 206 |
+
start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
|
| 207 |
+
except Exception:
|
| 208 |
+
continue # can't build a route without a valid start time
|
| 209 |
+
|
| 210 |
+
elapsed = a.get("elapsed_time", 0)
|
| 211 |
+
ended_dt = start_dt + timedelta(seconds=elapsed)
|
| 212 |
+
|
| 213 |
+
coords = []
|
| 214 |
+
for i, pt in enumerate(latlng):
|
| 215 |
+
offset_s = times[i] if i < len(times) else 0
|
| 216 |
+
ts = (start_dt + timedelta(seconds=offset_s)).isoformat()
|
| 217 |
+
c = {"lat": pt[0], "lng": pt[1], "timestamp": ts}
|
| 218 |
+
if i < len(hr_stream) and hr_stream[i]:
|
| 219 |
+
c["heart_rate"] = hr_stream[i]
|
| 220 |
+
coords.append(c)
|
| 221 |
+
|
| 222 |
+
# ending HR = last HR value in stream
|
| 223 |
+
ending_hr = None
|
| 224 |
+
for val in reversed(hr_stream):
|
| 225 |
+
if val:
|
| 226 |
+
ending_hr = val
|
| 227 |
+
break
|
| 228 |
+
# Back-fill ending_heart_rate onto the workout row
|
| 229 |
+
workout_rows[-1]["ending_heart_rate"] = ending_hr
|
| 230 |
+
|
| 231 |
+
route_rows.append({
|
| 232 |
+
"user_id": user_id,
|
| 233 |
+
"workout_type": workout_type,
|
| 234 |
+
"coordinates": coords,
|
| 235 |
+
"distance_meters": a.get("distance"),
|
| 236 |
+
"duration_seconds": a.get("moving_time"),
|
| 237 |
+
"started_at": start_date,
|
| 238 |
+
"ended_at": ended_dt.isoformat(),
|
| 239 |
+
"notes": a.get("name"),
|
| 240 |
+
})
|
| 241 |
+
|
| 242 |
+
if workout_rows:
|
| 243 |
+
await db.table("watch_data").insert(workout_rows).execute()
|
| 244 |
+
if route_rows:
|
| 245 |
+
await db.table("routes").insert(route_rows).execute()
|
| 246 |
+
|
| 247 |
+
return {
|
| 248 |
+
"synced": len(workout_rows),
|
| 249 |
+
"routes_saved": len(route_rows),
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@router.post("/nike/import")
|
| 254 |
+
async def nike_import(
|
| 255 |
+
file: UploadFile = File(...),
|
| 256 |
+
user_id: str = Depends(get_user_id),
|
| 257 |
+
):
|
| 258 |
+
"""Parse a Nike Run Club data-export ZIP or JSON and save activities to watch_data."""
|
| 259 |
+
content = await file.read()
|
| 260 |
+
activities: list[dict] = []
|
| 261 |
+
|
| 262 |
+
filename = (file.filename or "").lower()
|
| 263 |
+
if filename.endswith(".zip"):
|
| 264 |
+
try:
|
| 265 |
+
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
| 266 |
+
for name in zf.namelist():
|
| 267 |
+
if not name.lower().endswith(".json"):
|
| 268 |
+
continue
|
| 269 |
+
with zf.open(name) as f:
|
| 270 |
+
try:
|
| 271 |
+
data = json.load(f)
|
| 272 |
+
if isinstance(data, list):
|
| 273 |
+
activities.extend(data)
|
| 274 |
+
elif isinstance(data, dict) and ("startEpochMs" in data or "type" in data):
|
| 275 |
+
activities.append(data)
|
| 276 |
+
except Exception:
|
| 277 |
+
pass
|
| 278 |
+
except zipfile.BadZipFile:
|
| 279 |
+
return {"error": "Invalid ZIP file", "imported": 0, "routes_saved": 0}
|
| 280 |
+
else:
|
| 281 |
+
try:
|
| 282 |
+
data = json.loads(content)
|
| 283 |
+
activities = data if isinstance(data, list) else [data]
|
| 284 |
+
except Exception:
|
| 285 |
+
return {"error": "Invalid JSON file", "imported": 0, "routes_saved": 0}
|
| 286 |
+
|
| 287 |
+
workout_rows: list[dict] = []
|
| 288 |
+
route_rows: list[dict] = []
|
| 289 |
+
|
| 290 |
+
for a in activities:
|
| 291 |
+
# ── Timestamp ─────────────────────────────────────────────────────
|
| 292 |
+
start_ms = a.get("startEpochMs") or a.get("start_epoch_ms")
|
| 293 |
+
if start_ms:
|
| 294 |
+
start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc)
|
| 295 |
+
else:
|
| 296 |
+
continue # skip entries with no timestamp — we can't place them in time
|
| 297 |
+
|
| 298 |
+
# ── Duration ──────────────────────────────────────────────────────
|
| 299 |
+
active_ms = a.get("activeTime") or a.get("active_duration_ms") or 0
|
| 300 |
+
duration_min = round(active_ms / 60000, 1) if active_ms else None
|
| 301 |
+
|
| 302 |
+
# ── Activity type ─────────────────────────────────────────────────
|
| 303 |
+
workout_type = NRC_TYPE_MAP.get(a.get("type", "").lower(), "other")
|
| 304 |
+
|
| 305 |
+
# ── Tags (NRC stores extra fields here) ───────────────────────────
|
| 306 |
+
tags = a.get("tags", {})
|
| 307 |
+
|
| 308 |
+
def _tag(key: str):
|
| 309 |
+
return tags.get(key) or tags.get(f"com.nike.{key}")
|
| 310 |
+
|
| 311 |
+
# ── Distance ──────────────────────────────────────────────────────
|
| 312 |
+
distance_m = None
|
| 313 |
+
dist_raw = _tag("distance") or a.get("distance")
|
| 314 |
+
if dist_raw is not None:
|
| 315 |
+
try:
|
| 316 |
+
if isinstance(dist_raw, dict):
|
| 317 |
+
val = float(dist_raw.get("value", 0))
|
| 318 |
+
unit = dist_raw.get("unit", "KM").upper()
|
| 319 |
+
distance_m = round(val * 1000 if unit in ("KM", "KILOMETERS") else val * 1609.34, 1)
|
| 320 |
+
else:
|
| 321 |
+
distance_m = round(float(dist_raw) * 1000, 1) # NRC tags are in km
|
| 322 |
+
except (ValueError, TypeError):
|
| 323 |
+
pass
|
| 324 |
+
|
| 325 |
+
# ── Calories ──────────────────────────────────────────────────────
|
| 326 |
+
calories = None
|
| 327 |
+
cal_raw = _tag("calories") or a.get("calories")
|
| 328 |
+
if cal_raw is not None:
|
| 329 |
+
try:
|
| 330 |
+
calories = float(cal_raw.get("value", 0) if isinstance(cal_raw, dict) else cal_raw)
|
| 331 |
+
except (ValueError, TypeError):
|
| 332 |
+
pass
|
| 333 |
+
|
| 334 |
+
# ── Heart rate + GPS from metrics ─────────────────────────────────
|
| 335 |
+
avg_hr = max_hr = ending_hr = None
|
| 336 |
+
lat_entries: list[dict] = []
|
| 337 |
+
lng_entries: list[dict] = []
|
| 338 |
+
|
| 339 |
+
for metric in a.get("metrics", []):
|
| 340 |
+
mtype = metric.get("type", "").upper()
|
| 341 |
+
values = metric.get("values", [])
|
| 342 |
+
if not values:
|
| 343 |
+
continue
|
| 344 |
+
|
| 345 |
+
if mtype == "HEART_RATE":
|
| 346 |
+
hr_vals = [v["value"] for v in values if v.get("value") is not None]
|
| 347 |
+
if hr_vals:
|
| 348 |
+
avg_hr = round(sum(hr_vals) / len(hr_vals))
|
| 349 |
+
max_hr = int(max(hr_vals))
|
| 350 |
+
ending_hr = int(hr_vals[-1])
|
| 351 |
+
elif mtype == "LATITUDE":
|
| 352 |
+
lat_entries = values
|
| 353 |
+
elif mtype == "LONGITUDE":
|
| 354 |
+
lng_entries = values
|
| 355 |
+
|
| 356 |
+
# Also check summaries block for distance/calories if still missing
|
| 357 |
+
for s in a.get("summaries", []):
|
| 358 |
+
stype = s.get("metric", "").upper()
|
| 359 |
+
val = s.get("value")
|
| 360 |
+
if val is None:
|
| 361 |
+
continue
|
| 362 |
+
if stype == "DISTANCE" and distance_m is None:
|
| 363 |
+
distance_m = round(float(val) * 1000, 1)
|
| 364 |
+
elif stype == "CALORIES" and calories is None:
|
| 365 |
+
calories = float(val)
|
| 366 |
+
|
| 367 |
+
# ── Workout row ───────────────────────────────────────────────────
|
| 368 |
+
workout_rows.append({
|
| 369 |
+
"user_id": user_id,
|
| 370 |
+
"type": "workout",
|
| 371 |
+
"device": "nike_run_club",
|
| 372 |
+
"timestamp": start_dt.isoformat(),
|
| 373 |
+
"workout_type": workout_type,
|
| 374 |
+
"duration_minutes": duration_min,
|
| 375 |
+
"distance_meters": distance_m,
|
| 376 |
+
"calories_burned": calories,
|
| 377 |
+
"avg_heart_rate": avg_hr,
|
| 378 |
+
"max_heart_rate": max_hr,
|
| 379 |
+
"ending_heart_rate": ending_hr,
|
| 380 |
+
"notes": _tag("name") or a.get("name") or a.get("title"),
|
| 381 |
+
})
|
| 382 |
+
|
| 383 |
+
# ── GPS route ─────────────────────────────────────────────────────
|
| 384 |
+
if len(lat_entries) >= 2 and len(lng_entries) >= 2:
|
| 385 |
+
coords = []
|
| 386 |
+
for i, lat_entry in enumerate(lat_entries):
|
| 387 |
+
if i >= len(lng_entries):
|
| 388 |
+
break
|
| 389 |
+
ts_ms = lat_entry.get("startEpochMs") or start_ms
|
| 390 |
+
coords.append({
|
| 391 |
+
"lat": lat_entry.get("value"),
|
| 392 |
+
"lng": lng_entries[i].get("value"),
|
| 393 |
+
"timestamp": datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat(),
|
| 394 |
+
})
|
| 395 |
+
if len(coords) >= 2:
|
| 396 |
+
ended_dt = start_dt + timedelta(milliseconds=active_ms or 0)
|
| 397 |
+
route_rows.append({
|
| 398 |
+
"user_id": user_id,
|
| 399 |
+
"workout_type": workout_type,
|
| 400 |
+
"coordinates": coords,
|
| 401 |
+
"distance_meters": distance_m,
|
| 402 |
+
"duration_seconds": active_ms // 1000 if active_ms else None,
|
| 403 |
+
"started_at": start_dt.isoformat(),
|
| 404 |
+
"ended_at": ended_dt.isoformat(),
|
| 405 |
+
"notes": _tag("name") or a.get("name"),
|
| 406 |
+
})
|
| 407 |
+
|
| 408 |
+
db = await get_db()
|
| 409 |
+
if workout_rows:
|
| 410 |
+
await db.table("watch_data").insert(workout_rows).execute()
|
| 411 |
+
if route_rows:
|
| 412 |
+
await db.table("routes").insert(route_rows).execute()
|
| 413 |
+
|
| 414 |
+
return {"imported": len(workout_rows), "routes_saved": len(route_rows)}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
async def _refresh_strava_token_if_needed(db, integration: dict) -> str:
|
| 418 |
+
expires_at = datetime.fromisoformat(integration["expires_at"])
|
| 419 |
+
if datetime.now(timezone.utc) < expires_at:
|
| 420 |
+
return integration["access_token"]
|
| 421 |
+
|
| 422 |
+
async with httpx.AsyncClient() as client:
|
| 423 |
+
resp = await client.post(STRAVA_TOKEN_URL, data={
|
| 424 |
+
"client_id": STRAVA_CLIENT_ID,
|
| 425 |
+
"client_secret": STRAVA_CLIENT_SECRET,
|
| 426 |
+
"refresh_token": integration["refresh_token"],
|
| 427 |
+
"grant_type": "refresh_token",
|
| 428 |
+
})
|
| 429 |
+
|
| 430 |
+
if resp.status_code != 200:
|
| 431 |
+
raise HTTPException(status_code=502, detail="Failed to refresh Strava token")
|
| 432 |
+
|
| 433 |
+
data = resp.json()
|
| 434 |
+
if "access_token" not in data:
|
| 435 |
+
raise HTTPException(status_code=502, detail="Strava token response missing access_token")
|
| 436 |
+
|
| 437 |
+
await (
|
| 438 |
+
db.table("user_integrations")
|
| 439 |
+
.update({
|
| 440 |
+
"access_token": data["access_token"],
|
| 441 |
+
"refresh_token": data.get("refresh_token", integration["refresh_token"]),
|
| 442 |
+
"expires_at": datetime.fromtimestamp(data["expires_at"], tz=timezone.utc).isoformat(),
|
| 443 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 444 |
+
})
|
| 445 |
+
.eq("id", integration["id"])
|
| 446 |
+
.execute()
|
| 447 |
+
)
|
| 448 |
+
return data["access_token"]
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# ── Shared helpers ────────────────────────────────────────────────────────────
|
| 452 |
+
|
| 453 |
+
def _haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
| 454 |
+
R = 6371000
|
| 455 |
+
p = math.pi / 180
|
| 456 |
+
a = (math.sin((lat2 - lat1) * p / 2) ** 2 +
|
| 457 |
+
math.cos(lat1 * p) * math.cos(lat2 * p) *
|
| 458 |
+
math.sin((lng2 - lng1) * p / 2) ** 2)
|
| 459 |
+
return 2 * R * math.asin(math.sqrt(max(0.0, min(1.0, a))))
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def _classify_activity(name: str) -> str:
|
| 463 |
+
n = name.lower()
|
| 464 |
+
if any(w in n for w in ("run", "jog")): return "running"
|
| 465 |
+
if any(w in n for w in ("cycl", "bike", "rid")): return "cycling"
|
| 466 |
+
if any(w in n for w in ("walk",)): return "walking"
|
| 467 |
+
if any(w in n for w in ("hike", "trail")): return "hiking"
|
| 468 |
+
if any(w in n for w in ("swim",)): return "swimming"
|
| 469 |
+
if any(w in n for w in ("weight", "strength", "lift", "gym")): return "weightlifting"
|
| 470 |
+
return "other"
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _strip_ns(root: ET.Element) -> ET.Element:
|
| 474 |
+
"""Remove XML namespace prefixes so we can find tags by local name."""
|
| 475 |
+
for el in root.iter():
|
| 476 |
+
if "}" in el.tag:
|
| 477 |
+
el.tag = el.tag.split("}", 1)[1]
|
| 478 |
+
return root
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def _parse_apple_date(s: str) -> datetime:
|
| 482 |
+
s = s.strip()
|
| 483 |
+
for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%z"):
|
| 484 |
+
try:
|
| 485 |
+
return datetime.strptime(s, fmt)
|
| 486 |
+
except ValueError:
|
| 487 |
+
pass
|
| 488 |
+
raise ValueError(f"Cannot parse Apple Health date: {s!r}")
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
# ── Google Health API (Fitbit wearable data via Google OAuth) ─────────────────
|
| 492 |
+
# Fitbit Web API was deprecated; new apps register at Google Cloud Console.
|
| 493 |
+
# Docs: https://developers.google.com/health/api
|
| 494 |
+
|
| 495 |
+
GOOGLE_HEALTH_CLIENT_ID = os.getenv("GOOGLE_HEALTH_CLIENT_ID", "")
|
| 496 |
+
GOOGLE_HEALTH_CLIENT_SECRET = os.getenv("GOOGLE_HEALTH_CLIENT_SECRET", "")
|
| 497 |
+
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
| 498 |
+
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
| 499 |
+
GOOGLE_HEALTH_BASE = "https://health.googleapis.com/v4"
|
| 500 |
+
|
| 501 |
+
GOOGLE_HEALTH_SCOPES = " ".join([
|
| 502 |
+
"https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
|
| 503 |
+
"https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
|
| 504 |
+
"https://www.googleapis.com/auth/googlehealth.sleep.readonly",
|
| 505 |
+
])
|
| 506 |
+
|
| 507 |
+
GOOGLE_EXERCISE_TYPE_MAP = {
|
| 508 |
+
"RUNNING": "running",
|
| 509 |
+
"WALKING": "walking",
|
| 510 |
+
"BIKING": "cycling",
|
| 511 |
+
"SWIMMING": "swimming",
|
| 512 |
+
"HIKING": "hiking",
|
| 513 |
+
"YOGA": "other",
|
| 514 |
+
"PILATES": "other",
|
| 515 |
+
"WORKOUT": "other",
|
| 516 |
+
"HIIT": "other",
|
| 517 |
+
"WEIGHTLIFTING": "weightlifting",
|
| 518 |
+
"STRENGTH_TRAINING": "weightlifting",
|
| 519 |
+
"OTHER": "other",
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def _parse_google_time(t) -> str | None:
|
| 524 |
+
"""Accept RFC 3339 string or proto Timestamp dict {seconds, nanos}."""
|
| 525 |
+
if isinstance(t, str):
|
| 526 |
+
return t.replace("Z", "+00:00")
|
| 527 |
+
if isinstance(t, dict):
|
| 528 |
+
secs = t.get("seconds") or t.get("epochSeconds")
|
| 529 |
+
if secs:
|
| 530 |
+
return datetime.fromtimestamp(int(secs), tz=timezone.utc).isoformat()
|
| 531 |
+
return None
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
@router.get("/fitbit/connect")
|
| 535 |
+
async def fitbit_connect(user_id: str = Depends(get_user_id)):
|
| 536 |
+
callback = f"{BACKEND_URL}/integrations/fitbit/callback"
|
| 537 |
+
url = (
|
| 538 |
+
f"{GOOGLE_AUTH_URL}"
|
| 539 |
+
f"?client_id={GOOGLE_HEALTH_CLIENT_ID}"
|
| 540 |
+
f"&response_type=code"
|
| 541 |
+
f"&scope={urllib.parse.quote(GOOGLE_HEALTH_SCOPES)}"
|
| 542 |
+
f"&redirect_uri={urllib.parse.quote(callback, safe='')}"
|
| 543 |
+
f"&state={_make_state(user_id)}"
|
| 544 |
+
f"&access_type=offline"
|
| 545 |
+
f"&prompt=consent"
|
| 546 |
+
)
|
| 547 |
+
return {"url": url}
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
@router.get("/fitbit/callback")
|
| 551 |
+
async def fitbit_callback(code: str = "", state: str = "", error: str = ""):
|
| 552 |
+
if error or not code:
|
| 553 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?error={error or 'access_denied'}")
|
| 554 |
+
user_id = _verify_state(state)
|
| 555 |
+
if not user_id:
|
| 556 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?error=invalid_state")
|
| 557 |
+
|
| 558 |
+
callback = f"{BACKEND_URL}/integrations/fitbit/callback"
|
| 559 |
+
async with httpx.AsyncClient() as client:
|
| 560 |
+
resp = await client.post(GOOGLE_TOKEN_URL, data={
|
| 561 |
+
"code": code,
|
| 562 |
+
"grant_type": "authorization_code",
|
| 563 |
+
"redirect_uri": callback,
|
| 564 |
+
"client_id": GOOGLE_HEALTH_CLIENT_ID,
|
| 565 |
+
"client_secret": GOOGLE_HEALTH_CLIENT_SECRET,
|
| 566 |
+
})
|
| 567 |
+
if resp.status_code != 200:
|
| 568 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?error=token_exchange_failed")
|
| 569 |
+
data = resp.json()
|
| 570 |
+
|
| 571 |
+
db = await get_db()
|
| 572 |
+
await db.table("user_integrations").upsert({
|
| 573 |
+
"user_id": user_id,
|
| 574 |
+
"provider": "fitbit",
|
| 575 |
+
"access_token": data["access_token"],
|
| 576 |
+
"refresh_token": data.get("refresh_token"),
|
| 577 |
+
"expires_at": (datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 3600))).isoformat(),
|
| 578 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 579 |
+
}, on_conflict="user_id,provider").execute()
|
| 580 |
+
return RedirectResponse(f"{FRONTEND_URL}/log?connected=fitbit")
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
@router.post("/fitbit/sync")
|
| 584 |
+
async def fitbit_sync(user_id: str = Depends(get_user_id)):
|
| 585 |
+
db = await get_db()
|
| 586 |
+
integration = await (
|
| 587 |
+
db.table("user_integrations").select("*")
|
| 588 |
+
.eq("user_id", user_id).eq("provider", "fitbit").single().execute()
|
| 589 |
+
)
|
| 590 |
+
if not integration.data:
|
| 591 |
+
return {"synced": 0, "error": "Fitbit not connected"}
|
| 592 |
+
|
| 593 |
+
token = await _refresh_fitbit_token_if_needed(db, integration.data)
|
| 594 |
+
after = (datetime.now(timezone.utc) - timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 595 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 596 |
+
|
| 597 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 598 |
+
ex_resp = await client.get(
|
| 599 |
+
f"{GOOGLE_HEALTH_BASE}/users/me/dataTypes/exercise/dataPoints",
|
| 600 |
+
headers=headers,
|
| 601 |
+
params={"filter": f'exercise.interval.startTime >= "{after}"', "pageSize": 100},
|
| 602 |
+
)
|
| 603 |
+
sl_resp = await client.get(
|
| 604 |
+
f"{GOOGLE_HEALTH_BASE}/users/me/dataTypes/sleep/dataPoints",
|
| 605 |
+
headers=headers,
|
| 606 |
+
params={"filter": f'sleep.interval.startTime >= "{after}"', "pageSize": 100},
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
exercises = ex_resp.json().get("dataPoints", []) if ex_resp.status_code == 200 else []
|
| 610 |
+
sleeps = sl_resp.json().get("dataPoints", []) if sl_resp.status_code == 200 else []
|
| 611 |
+
|
| 612 |
+
rows: list[dict] = []
|
| 613 |
+
|
| 614 |
+
for pt in exercises:
|
| 615 |
+
ex = (pt.get("data") or {}).get("exercise", {})
|
| 616 |
+
ivl = ex.get("interval", {})
|
| 617 |
+
start = _parse_google_time(ivl.get("startTime"))
|
| 618 |
+
if not start:
|
| 619 |
+
continue
|
| 620 |
+
try:
|
| 621 |
+
start_dt = datetime.fromisoformat(start)
|
| 622 |
+
except ValueError:
|
| 623 |
+
continue
|
| 624 |
+
|
| 625 |
+
dur_min = None
|
| 626 |
+
active = ex.get("activeDuration", "")
|
| 627 |
+
if isinstance(active, str) and active.endswith("s"):
|
| 628 |
+
try:
|
| 629 |
+
dur_min = round(float(active[:-1]) / 60, 1)
|
| 630 |
+
except ValueError:
|
| 631 |
+
pass
|
| 632 |
+
if dur_min is None:
|
| 633 |
+
end_str = _parse_google_time(ivl.get("endTime"))
|
| 634 |
+
if end_str:
|
| 635 |
+
try:
|
| 636 |
+
dur_min = round((datetime.fromisoformat(end_str) - start_dt).total_seconds() / 60, 1)
|
| 637 |
+
except ValueError:
|
| 638 |
+
pass
|
| 639 |
+
|
| 640 |
+
metrics = ex.get("metricsSummary", {})
|
| 641 |
+
dist_mm = metrics.get("distanceMillimeters")
|
| 642 |
+
rows.append({
|
| 643 |
+
"user_id": user_id,
|
| 644 |
+
"type": "workout",
|
| 645 |
+
"device": "fitbit",
|
| 646 |
+
"timestamp": start_dt.isoformat(),
|
| 647 |
+
"workout_type": GOOGLE_EXERCISE_TYPE_MAP.get(ex.get("exerciseType", ""), "other"),
|
| 648 |
+
"duration_minutes": dur_min,
|
| 649 |
+
"distance_meters": round(float(dist_mm) / 1000, 1) if dist_mm else None,
|
| 650 |
+
"calories_burned": metrics.get("caloriesKcal"),
|
| 651 |
+
"avg_heart_rate": metrics.get("averageHeartRateBeatsPerMinute"),
|
| 652 |
+
})
|
| 653 |
+
|
| 654 |
+
for pt in sleeps:
|
| 655 |
+
sl = (pt.get("data") or {}).get("sleep", {})
|
| 656 |
+
ivl = sl.get("interval", {})
|
| 657 |
+
start = _parse_google_time(ivl.get("startTime"))
|
| 658 |
+
end = _parse_google_time(ivl.get("endTime"))
|
| 659 |
+
if not start:
|
| 660 |
+
continue
|
| 661 |
+
dur_hrs = None
|
| 662 |
+
if end:
|
| 663 |
+
try:
|
| 664 |
+
s = datetime.fromisoformat(start)
|
| 665 |
+
e = datetime.fromisoformat(end)
|
| 666 |
+
dur_hrs = round((e - s).total_seconds() / 3600, 2)
|
| 667 |
+
except ValueError:
|
| 668 |
+
pass
|
| 669 |
+
rows.append({
|
| 670 |
+
"user_id": user_id,
|
| 671 |
+
"type": "reading",
|
| 672 |
+
"device": "fitbit",
|
| 673 |
+
"timestamp": start,
|
| 674 |
+
"sleep_hours": dur_hrs,
|
| 675 |
+
})
|
| 676 |
+
|
| 677 |
+
if rows:
|
| 678 |
+
await db.table("watch_data").insert(rows).execute()
|
| 679 |
+
return {
|
| 680 |
+
"synced": len([r for r in rows if r["type"] == "workout"]),
|
| 681 |
+
"sleep_synced": len([r for r in rows if r["type"] == "reading"]),
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
async def _refresh_fitbit_token_if_needed(db, integration: dict) -> str:
|
| 686 |
+
expires_at = datetime.fromisoformat(integration["expires_at"])
|
| 687 |
+
if datetime.now(timezone.utc) < expires_at:
|
| 688 |
+
return integration["access_token"]
|
| 689 |
+
|
| 690 |
+
async with httpx.AsyncClient() as client:
|
| 691 |
+
resp = await client.post(GOOGLE_TOKEN_URL, data={
|
| 692 |
+
"grant_type": "refresh_token",
|
| 693 |
+
"refresh_token": integration["refresh_token"],
|
| 694 |
+
"client_id": GOOGLE_HEALTH_CLIENT_ID,
|
| 695 |
+
"client_secret": GOOGLE_HEALTH_CLIENT_SECRET,
|
| 696 |
+
})
|
| 697 |
+
if resp.status_code != 200:
|
| 698 |
+
raise HTTPException(status_code=502, detail="Failed to refresh Fitbit token")
|
| 699 |
+
|
| 700 |
+
data = resp.json()
|
| 701 |
+
await (
|
| 702 |
+
db.table("user_integrations").update({
|
| 703 |
+
"access_token": data["access_token"],
|
| 704 |
+
"refresh_token": data.get("refresh_token", integration["refresh_token"]),
|
| 705 |
+
"expires_at": (datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 3600))).isoformat(),
|
| 706 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 707 |
+
}).eq("id", integration["id"]).execute()
|
| 708 |
+
)
|
| 709 |
+
return data["access_token"]
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
# ── Garmin (GPX / TCX file import) ───────────────────────────────────────────
|
| 713 |
+
|
| 714 |
+
@router.post("/garmin/import")
|
| 715 |
+
async def garmin_import(file: UploadFile = File(...), user_id: str = Depends(get_user_id)):
|
| 716 |
+
content = await file.read()
|
| 717 |
+
filename = (file.filename or "").lower()
|
| 718 |
+
if filename.endswith(".gpx"):
|
| 719 |
+
workout_rows, route_rows = _parse_gpx(content, user_id)
|
| 720 |
+
elif filename.endswith(".tcx"):
|
| 721 |
+
workout_rows, route_rows = _parse_tcx(content, user_id)
|
| 722 |
+
else:
|
| 723 |
+
return {"error": "Upload a .gpx or .tcx file exported from Garmin Connect.", "imported": 0, "routes_saved": 0}
|
| 724 |
+
|
| 725 |
+
db = await get_db()
|
| 726 |
+
if workout_rows:
|
| 727 |
+
await db.table("watch_data").insert(workout_rows).execute()
|
| 728 |
+
if route_rows:
|
| 729 |
+
await db.table("routes").insert(route_rows).execute()
|
| 730 |
+
return {"imported": len(workout_rows), "routes_saved": len(route_rows)}
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def _parse_gpx(content: bytes, user_id: str) -> tuple[list, list]:
|
| 734 |
+
try:
|
| 735 |
+
root = _strip_ns(ET.fromstring(content))
|
| 736 |
+
except ET.ParseError:
|
| 737 |
+
return [], []
|
| 738 |
+
|
| 739 |
+
workout_rows, route_rows = [], []
|
| 740 |
+
|
| 741 |
+
for trk in root.findall(".//trk"):
|
| 742 |
+
name_el = trk.find("name")
|
| 743 |
+
name = name_el.text if name_el is not None else None
|
| 744 |
+
type_el = trk.find("type")
|
| 745 |
+
type_hint = (type_el.text if type_el is not None else name) or ""
|
| 746 |
+
workout_type = _classify_activity(type_hint)
|
| 747 |
+
|
| 748 |
+
coords, hr_vals = [], []
|
| 749 |
+
for pt in trk.findall(".//trkpt"):
|
| 750 |
+
lat, lon = pt.get("lat"), pt.get("lon")
|
| 751 |
+
time_el = pt.find("time")
|
| 752 |
+
if lat is None or lon is None or time_el is None:
|
| 753 |
+
continue
|
| 754 |
+
coord: dict = {"lat": float(lat), "lng": float(lon), "timestamp": time_el.text}
|
| 755 |
+
ele_el = pt.find("ele")
|
| 756 |
+
if ele_el is not None:
|
| 757 |
+
try:
|
| 758 |
+
coord["altitude"] = float(ele_el.text)
|
| 759 |
+
except (ValueError, TypeError):
|
| 760 |
+
pass
|
| 761 |
+
hr_el = pt.find(".//hr") or pt.find(".//HeartRateBpm/Value")
|
| 762 |
+
if hr_el is not None and hr_el.text:
|
| 763 |
+
try:
|
| 764 |
+
v = int(hr_el.text)
|
| 765 |
+
hr_vals.append(v)
|
| 766 |
+
coord["heart_rate"] = v
|
| 767 |
+
except (ValueError, TypeError):
|
| 768 |
+
pass
|
| 769 |
+
coords.append(coord)
|
| 770 |
+
|
| 771 |
+
if len(coords) < 2:
|
| 772 |
+
continue
|
| 773 |
+
|
| 774 |
+
try:
|
| 775 |
+
start_dt = datetime.fromisoformat(coords[0]["timestamp"].replace("Z", "+00:00"))
|
| 776 |
+
end_dt = datetime.fromisoformat(coords[-1]["timestamp"].replace("Z", "+00:00"))
|
| 777 |
+
except (ValueError, KeyError):
|
| 778 |
+
continue
|
| 779 |
+
|
| 780 |
+
duration_s = int((end_dt - start_dt).total_seconds())
|
| 781 |
+
dist_m = sum(_haversine(coords[i-1]["lat"], coords[i-1]["lng"],
|
| 782 |
+
coords[i]["lat"], coords[i]["lng"])
|
| 783 |
+
for i in range(1, len(coords)))
|
| 784 |
+
|
| 785 |
+
workout_rows.append({
|
| 786 |
+
"user_id": user_id, "type": "workout", "device": "garmin",
|
| 787 |
+
"timestamp": start_dt.isoformat(),
|
| 788 |
+
"workout_type": workout_type,
|
| 789 |
+
"duration_minutes": round(duration_s / 60, 1),
|
| 790 |
+
"distance_meters": round(dist_m, 1),
|
| 791 |
+
"avg_heart_rate": round(sum(hr_vals) / len(hr_vals)) if hr_vals else None,
|
| 792 |
+
"max_heart_rate": max(hr_vals) if hr_vals else None,
|
| 793 |
+
"ending_heart_rate": hr_vals[-1] if hr_vals else None,
|
| 794 |
+
"notes": name,
|
| 795 |
+
})
|
| 796 |
+
route_rows.append({
|
| 797 |
+
"user_id": user_id, "workout_type": workout_type,
|
| 798 |
+
"coordinates": coords, "distance_meters": round(dist_m, 1),
|
| 799 |
+
"duration_seconds": duration_s,
|
| 800 |
+
"started_at": start_dt.isoformat(), "ended_at": end_dt.isoformat(),
|
| 801 |
+
"notes": name,
|
| 802 |
+
})
|
| 803 |
+
|
| 804 |
+
return workout_rows, route_rows
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def _parse_tcx(content: bytes, user_id: str) -> tuple[list, list]:
|
| 808 |
+
try:
|
| 809 |
+
root = _strip_ns(ET.fromstring(content))
|
| 810 |
+
except ET.ParseError:
|
| 811 |
+
return [], []
|
| 812 |
+
|
| 813 |
+
workout_rows, route_rows = [], []
|
| 814 |
+
|
| 815 |
+
for activity in root.findall(".//Activity"):
|
| 816 |
+
sport = activity.get("Sport", "other")
|
| 817 |
+
workout_type = _classify_activity(sport)
|
| 818 |
+
id_el = activity.find("Id")
|
| 819 |
+
if id_el is None:
|
| 820 |
+
continue
|
| 821 |
+
try:
|
| 822 |
+
start_dt = datetime.fromisoformat(id_el.text.replace("Z", "+00:00"))
|
| 823 |
+
except (ValueError, AttributeError):
|
| 824 |
+
continue
|
| 825 |
+
|
| 826 |
+
total_time_s = 0
|
| 827 |
+
total_dist_m = 0.0
|
| 828 |
+
total_cal = 0
|
| 829 |
+
hr_vals, coords = [], []
|
| 830 |
+
|
| 831 |
+
for lap in activity.findall(".//Lap"):
|
| 832 |
+
for tag, target in [("TotalTimeSeconds", None), ("DistanceMeters", None),
|
| 833 |
+
("Calories", None)]:
|
| 834 |
+
el = lap.find(tag)
|
| 835 |
+
if el is not None and el.text:
|
| 836 |
+
try:
|
| 837 |
+
val = float(el.text)
|
| 838 |
+
if tag == "TotalTimeSeconds": total_time_s += int(val)
|
| 839 |
+
elif tag == "DistanceMeters": total_dist_m += val
|
| 840 |
+
elif tag == "Calories": total_cal += int(val)
|
| 841 |
+
except (ValueError, TypeError):
|
| 842 |
+
pass
|
| 843 |
+
|
| 844 |
+
for tp in lap.findall(".//Trackpoint"):
|
| 845 |
+
time_el = tp.find("Time")
|
| 846 |
+
pos_el = tp.find("Position")
|
| 847 |
+
hr_el = tp.find(".//HeartRateBpm/Value") or tp.find("HeartRateBpm/Value")
|
| 848 |
+
if pos_el is not None and time_el is not None:
|
| 849 |
+
lat_el = pos_el.find("LatitudeDegrees")
|
| 850 |
+
lng_el = pos_el.find("LongitudeDegrees")
|
| 851 |
+
if lat_el is not None and lng_el is not None:
|
| 852 |
+
try:
|
| 853 |
+
coord: dict = {
|
| 854 |
+
"lat": float(lat_el.text),
|
| 855 |
+
"lng": float(lng_el.text),
|
| 856 |
+
"timestamp": time_el.text,
|
| 857 |
+
}
|
| 858 |
+
if hr_el is not None and hr_el.text:
|
| 859 |
+
v = int(hr_el.text)
|
| 860 |
+
hr_vals.append(v)
|
| 861 |
+
coord["heart_rate"] = v
|
| 862 |
+
coords.append(coord)
|
| 863 |
+
except (ValueError, TypeError):
|
| 864 |
+
pass
|
| 865 |
+
|
| 866 |
+
end_dt = start_dt + timedelta(seconds=total_time_s)
|
| 867 |
+
workout_rows.append({
|
| 868 |
+
"user_id": user_id, "type": "workout", "device": "garmin",
|
| 869 |
+
"timestamp": start_dt.isoformat(),
|
| 870 |
+
"workout_type": workout_type,
|
| 871 |
+
"duration_minutes": round(total_time_s / 60, 1),
|
| 872 |
+
"distance_meters": round(total_dist_m, 1) if total_dist_m else None,
|
| 873 |
+
"calories_burned": total_cal if total_cal else None,
|
| 874 |
+
"avg_heart_rate": round(sum(hr_vals) / len(hr_vals)) if hr_vals else None,
|
| 875 |
+
"max_heart_rate": max(hr_vals) if hr_vals else None,
|
| 876 |
+
"ending_heart_rate": hr_vals[-1] if hr_vals else None,
|
| 877 |
+
})
|
| 878 |
+
if len(coords) >= 2:
|
| 879 |
+
route_rows.append({
|
| 880 |
+
"user_id": user_id, "workout_type": workout_type,
|
| 881 |
+
"coordinates": coords,
|
| 882 |
+
"distance_meters": round(total_dist_m, 1) if total_dist_m else None,
|
| 883 |
+
"duration_seconds": total_time_s,
|
| 884 |
+
"started_at": start_dt.isoformat(), "ended_at": end_dt.isoformat(),
|
| 885 |
+
})
|
| 886 |
+
|
| 887 |
+
return workout_rows, route_rows
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
# ── Apple Health (XML / ZIP export) ──────────────────────────────────────────
|
| 891 |
+
|
| 892 |
+
APPLE_WORKOUT_MAP = {
|
| 893 |
+
"HKWorkoutActivityTypeRunning": "running",
|
| 894 |
+
"HKWorkoutActivityTypeCycling": "cycling",
|
| 895 |
+
"HKWorkoutActivityTypeWalking": "walking",
|
| 896 |
+
"HKWorkoutActivityTypeHiking": "hiking",
|
| 897 |
+
"HKWorkoutActivityTypeSwimming": "swimming",
|
| 898 |
+
"HKWorkoutActivityTypeTraditionalStrengthTraining": "weightlifting",
|
| 899 |
+
"HKWorkoutActivityTypeFunctionalStrengthTraining": "weightlifting",
|
| 900 |
+
"HKWorkoutActivityTypeHighIntensityIntervalTraining": "other",
|
| 901 |
+
"HKWorkoutActivityTypeYoga": "other",
|
| 902 |
+
"HKWorkoutActivityTypeCrossTraining": "other",
|
| 903 |
+
"HKWorkoutActivityTypeElliptical": "other",
|
| 904 |
+
"HKWorkoutActivityTypePilates": "other",
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
@router.post("/apple/import")
|
| 909 |
+
async def apple_import(file: UploadFile = File(...), user_id: str = Depends(get_user_id)):
|
| 910 |
+
content = await file.read()
|
| 911 |
+
filename = (file.filename or "").lower()
|
| 912 |
+
|
| 913 |
+
if filename.endswith(".zip"):
|
| 914 |
+
try:
|
| 915 |
+
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
| 916 |
+
xml_name = next(
|
| 917 |
+
(n for n in zf.namelist() if n.lower().endswith("export.xml")), None
|
| 918 |
+
)
|
| 919 |
+
if xml_name is None:
|
| 920 |
+
return {"error": "No export.xml found in ZIP.", "imported": 0}
|
| 921 |
+
xml_bytes = zf.read(xml_name)
|
| 922 |
+
except zipfile.BadZipFile:
|
| 923 |
+
return {"error": "Invalid ZIP file.", "imported": 0}
|
| 924 |
+
elif filename.endswith(".xml"):
|
| 925 |
+
xml_bytes = content
|
| 926 |
+
else:
|
| 927 |
+
return {"error": "Upload export.xml or the ZIP from Health app → Export All Health Data.", "imported": 0}
|
| 928 |
+
|
| 929 |
+
workout_rows, reading_rows = _parse_apple_health(xml_bytes, user_id)
|
| 930 |
+
db = await get_db()
|
| 931 |
+
if workout_rows:
|
| 932 |
+
await db.table("watch_data").insert(workout_rows).execute()
|
| 933 |
+
if reading_rows:
|
| 934 |
+
await db.table("watch_data").insert(reading_rows).execute()
|
| 935 |
+
return {"imported": len(workout_rows), "readings_synced": len(reading_rows)}
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
def _parse_apple_health(xml_bytes: bytes, user_id: str) -> tuple[list, list]:
|
| 939 |
+
try:
|
| 940 |
+
root = ET.fromstring(xml_bytes)
|
| 941 |
+
except ET.ParseError:
|
| 942 |
+
return [], []
|
| 943 |
+
|
| 944 |
+
workout_rows: list[dict] = []
|
| 945 |
+
hr_by_day: dict[str, list[float]] = {}
|
| 946 |
+
steps_by_day: dict[str, int] = {}
|
| 947 |
+
sleep_by_day: dict[str, float] = {}
|
| 948 |
+
|
| 949 |
+
for w in root.findall("Workout"):
|
| 950 |
+
activity_type = w.get("workoutActivityType", "")
|
| 951 |
+
start_str = w.get("startDate", "")
|
| 952 |
+
end_str = w.get("endDate", "")
|
| 953 |
+
if not start_str:
|
| 954 |
+
continue
|
| 955 |
+
try:
|
| 956 |
+
start_dt = _parse_apple_date(start_str)
|
| 957 |
+
end_dt = _parse_apple_date(end_str) if end_str else start_dt
|
| 958 |
+
except ValueError:
|
| 959 |
+
continue
|
| 960 |
+
|
| 961 |
+
duration_s = int((end_dt - start_dt).total_seconds())
|
| 962 |
+
calories = avg_hr = max_hr = dist_m = None
|
| 963 |
+
|
| 964 |
+
for stat in w.findall("WorkoutStatistics"):
|
| 965 |
+
stype = stat.get("type", "")
|
| 966 |
+
if "ActiveEnergyBurned" in stype:
|
| 967 |
+
try:
|
| 968 |
+
calories = float(stat.get("sum") or stat.get("average") or 0) or None
|
| 969 |
+
except (ValueError, TypeError):
|
| 970 |
+
pass
|
| 971 |
+
elif stype == "HKQuantityTypeIdentifierHeartRate":
|
| 972 |
+
try:
|
| 973 |
+
avg_hr = round(float(stat.get("average") or 0)) or None
|
| 974 |
+
max_hr = round(float(stat.get("maximum") or 0)) or None
|
| 975 |
+
except (ValueError, TypeError):
|
| 976 |
+
pass
|
| 977 |
+
elif "Distance" in stype:
|
| 978 |
+
try:
|
| 979 |
+
val = float(stat.get("sum") or 0)
|
| 980 |
+
unit = stat.get("unit", "km").lower()
|
| 981 |
+
dist_m = round(val * (1000 if "km" in unit else 1609.34 if "mi" in unit else 1), 1) or None
|
| 982 |
+
except (ValueError, TypeError):
|
| 983 |
+
pass
|
| 984 |
+
|
| 985 |
+
workout_rows.append({
|
| 986 |
+
"user_id": user_id, "type": "workout", "device": "apple_health",
|
| 987 |
+
"timestamp": start_dt.isoformat(),
|
| 988 |
+
"workout_type": APPLE_WORKOUT_MAP.get(activity_type, "other"),
|
| 989 |
+
"duration_minutes": round(duration_s / 60, 1),
|
| 990 |
+
"distance_meters": dist_m,
|
| 991 |
+
"calories_burned": calories,
|
| 992 |
+
"avg_heart_rate": avg_hr,
|
| 993 |
+
"max_heart_rate": max_hr,
|
| 994 |
+
})
|
| 995 |
+
|
| 996 |
+
for rec in root.findall("Record"):
|
| 997 |
+
rtype = rec.get("type", "")
|
| 998 |
+
start_str = rec.get("startDate", "")
|
| 999 |
+
if not start_str:
|
| 1000 |
+
continue
|
| 1001 |
+
try:
|
| 1002 |
+
dt = _parse_apple_date(start_str)
|
| 1003 |
+
day = dt.strftime("%Y-%m-%d")
|
| 1004 |
+
except ValueError:
|
| 1005 |
+
continue
|
| 1006 |
+
val = rec.get("value", "")
|
| 1007 |
+
|
| 1008 |
+
if rtype == "HKQuantityTypeIdentifierHeartRate":
|
| 1009 |
+
try:
|
| 1010 |
+
hr_by_day.setdefault(day, []).append(float(val))
|
| 1011 |
+
except (ValueError, TypeError):
|
| 1012 |
+
pass
|
| 1013 |
+
elif rtype == "HKQuantityTypeIdentifierStepCount":
|
| 1014 |
+
try:
|
| 1015 |
+
steps_by_day[day] = steps_by_day.get(day, 0) + int(float(val))
|
| 1016 |
+
except (ValueError, TypeError):
|
| 1017 |
+
pass
|
| 1018 |
+
elif rtype == "HKCategoryTypeIdentifierSleepAnalysis" and val == "HKCategoryValueSleepAnalysisAsleep":
|
| 1019 |
+
end_str2 = rec.get("endDate", "")
|
| 1020 |
+
if end_str2:
|
| 1021 |
+
try:
|
| 1022 |
+
end_dt2 = _parse_apple_date(end_str2)
|
| 1023 |
+
hours = (end_dt2 - dt).total_seconds() / 3600
|
| 1024 |
+
sleep_by_day[day] = sleep_by_day.get(day, 0) + hours
|
| 1025 |
+
except ValueError:
|
| 1026 |
+
pass
|
| 1027 |
+
|
| 1028 |
+
reading_rows: list[dict] = []
|
| 1029 |
+
for day in sorted(set(list(hr_by_day) + list(steps_by_day) + list(sleep_by_day))):
|
| 1030 |
+
row: dict = {"user_id": user_id, "type": "reading", "device": "apple_health",
|
| 1031 |
+
"timestamp": f"{day}T00:00:00+00:00"}
|
| 1032 |
+
hrs = hr_by_day.get(day)
|
| 1033 |
+
if hrs:
|
| 1034 |
+
row["heart_rate"] = round(sum(hrs) / len(hrs))
|
| 1035 |
+
if day in steps_by_day:
|
| 1036 |
+
row["steps"] = steps_by_day[day]
|
| 1037 |
+
if day in sleep_by_day:
|
| 1038 |
+
row["sleep_hours"] = round(sleep_by_day[day], 2)
|
| 1039 |
+
reading_rows.append(row)
|
| 1040 |
+
|
| 1041 |
+
return workout_rows, reading_rows
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
+
# ── Google Fit (Takeout ZIP import) ──────────────────────────────────────────
|
| 1045 |
+
|
| 1046 |
+
@router.post("/google/import")
|
| 1047 |
+
async def google_fit_import(file: UploadFile = File(...), user_id: str = Depends(get_user_id)):
|
| 1048 |
+
"""
|
| 1049 |
+
Import from Google Takeout → Fit data export.
|
| 1050 |
+
Go to takeout.google.com, select only 'Fit', export as ZIP, then upload here.
|
| 1051 |
+
"""
|
| 1052 |
+
content = await file.read()
|
| 1053 |
+
if not (file.filename or "").lower().endswith(".zip"):
|
| 1054 |
+
return {"error": "Upload the ZIP file downloaded from takeout.google.com.", "imported": 0}
|
| 1055 |
+
|
| 1056 |
+
try:
|
| 1057 |
+
zf = zipfile.ZipFile(io.BytesIO(content))
|
| 1058 |
+
except zipfile.BadZipFile:
|
| 1059 |
+
return {"error": "Invalid ZIP file.", "imported": 0}
|
| 1060 |
+
|
| 1061 |
+
workout_rows: list[dict] = []
|
| 1062 |
+
reading_rows: list[dict] = []
|
| 1063 |
+
|
| 1064 |
+
with zf:
|
| 1065 |
+
names = zf.namelist()
|
| 1066 |
+
|
| 1067 |
+
# ── Session JSON files (individual activities) ─────────────────
|
| 1068 |
+
session_files = [n for n in names
|
| 1069 |
+
if "Activities" in n and n.endswith(".json")]
|
| 1070 |
+
for fname in session_files:
|
| 1071 |
+
try:
|
| 1072 |
+
data = json.loads(zf.read(fname))
|
| 1073 |
+
except (json.JSONDecodeError, KeyError):
|
| 1074 |
+
continue
|
| 1075 |
+
if not isinstance(data, dict):
|
| 1076 |
+
continue
|
| 1077 |
+
|
| 1078 |
+
start_str = data.get("startTime") or data.get("start_time_millis")
|
| 1079 |
+
end_str = data.get("endTime") or data.get("end_time_millis")
|
| 1080 |
+
if not start_str:
|
| 1081 |
+
continue
|
| 1082 |
+
|
| 1083 |
+
try:
|
| 1084 |
+
if isinstance(start_str, (int, float)):
|
| 1085 |
+
start_dt = datetime.fromtimestamp(start_str / 1000, tz=timezone.utc)
|
| 1086 |
+
end_dt = datetime.fromtimestamp((end_str or start_str) / 1000, tz=timezone.utc)
|
| 1087 |
+
else:
|
| 1088 |
+
start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
| 1089 |
+
end_dt = datetime.fromisoformat((end_str or start_str).replace("Z", "+00:00"))
|
| 1090 |
+
except (ValueError, TypeError):
|
| 1091 |
+
continue
|
| 1092 |
+
|
| 1093 |
+
activity_type = str(data.get("activityType", data.get("activity_type", 0)))
|
| 1094 |
+
workout_type = _google_fit_activity_type(activity_type)
|
| 1095 |
+
duration_s = int((end_dt - start_dt).total_seconds())
|
| 1096 |
+
|
| 1097 |
+
calories = dist_m = avg_hr = None
|
| 1098 |
+
for seg in data.get("activitySegment", {}).get("activityConfidence", []):
|
| 1099 |
+
pass # presence only; stats are in aggregate fields below
|
| 1100 |
+
try:
|
| 1101 |
+
calories = float(data.get("calories") or data.get("caloriesExpended") or 0) or None
|
| 1102 |
+
except (ValueError, TypeError):
|
| 1103 |
+
pass
|
| 1104 |
+
try:
|
| 1105 |
+
dist_m = float(data.get("distance") or data.get("distanceMeters") or 0) or None
|
| 1106 |
+
except (ValueError, TypeError):
|
| 1107 |
+
pass
|
| 1108 |
+
|
| 1109 |
+
# Heart rate from aggregate key
|
| 1110 |
+
hr_data = data.get("heartRate") or {}
|
| 1111 |
+
try:
|
| 1112 |
+
avg_hr = round(float(hr_data.get("avg") or hr_data.get("average") or 0)) or None
|
| 1113 |
+
except (ValueError, TypeError):
|
| 1114 |
+
pass
|
| 1115 |
+
|
| 1116 |
+
workout_rows.append({
|
| 1117 |
+
"user_id": user_id, "type": "workout", "device": "google_fit",
|
| 1118 |
+
"timestamp": start_dt.isoformat(),
|
| 1119 |
+
"workout_type": workout_type,
|
| 1120 |
+
"duration_minutes": round(duration_s / 60, 1),
|
| 1121 |
+
"distance_meters": dist_m,
|
| 1122 |
+
"calories_burned": calories,
|
| 1123 |
+
"avg_heart_rate": avg_hr,
|
| 1124 |
+
})
|
| 1125 |
+
|
| 1126 |
+
# ── Daily Summary CSV ──────────────────────────────────────────
|
| 1127 |
+
csv_file = next(
|
| 1128 |
+
(n for n in names if "Daily" in n and n.endswith(".csv")), None
|
| 1129 |
+
)
|
| 1130 |
+
if csv_file:
|
| 1131 |
+
try:
|
| 1132 |
+
text = zf.read(csv_file).decode("utf-8-sig")
|
| 1133 |
+
reader = csv.DictReader(io.StringIO(text))
|
| 1134 |
+
for row in reader:
|
| 1135 |
+
day = (row.get("Date") or "").strip()
|
| 1136 |
+
if not day:
|
| 1137 |
+
continue
|
| 1138 |
+
reading: dict = {"user_id": user_id, "type": "reading",
|
| 1139 |
+
"device": "google_fit",
|
| 1140 |
+
"timestamp": f"{day}T00:00:00+00:00"}
|
| 1141 |
+
for col, field in [
|
| 1142 |
+
("Step count", "steps"),
|
| 1143 |
+
("Calories (kcal)", "calories_burned"),
|
| 1144 |
+
("Average heart rate (bpm)", "heart_rate"),
|
| 1145 |
+
("Sleep duration", None),
|
| 1146 |
+
]:
|
| 1147 |
+
val = (row.get(col) or "").strip()
|
| 1148 |
+
if not val:
|
| 1149 |
+
continue
|
| 1150 |
+
if col == "Sleep duration":
|
| 1151 |
+
try:
|
| 1152 |
+
h, m, s = (val + ":0:0").split(":")[:3]
|
| 1153 |
+
reading["sleep_hours"] = round(int(h) + int(m) / 60 + int(s) / 3600, 2)
|
| 1154 |
+
except (ValueError, TypeError):
|
| 1155 |
+
pass
|
| 1156 |
+
elif field:
|
| 1157 |
+
try:
|
| 1158 |
+
reading[field] = round(float(val), 1)
|
| 1159 |
+
except (ValueError, TypeError):
|
| 1160 |
+
pass
|
| 1161 |
+
reading_rows.append(reading)
|
| 1162 |
+
except Exception:
|
| 1163 |
+
pass
|
| 1164 |
+
|
| 1165 |
+
db = await get_db()
|
| 1166 |
+
if workout_rows:
|
| 1167 |
+
await db.table("watch_data").insert(workout_rows).execute()
|
| 1168 |
+
if reading_rows:
|
| 1169 |
+
await db.table("watch_data").insert(reading_rows).execute()
|
| 1170 |
+
return {"imported": len(workout_rows), "readings_synced": len(reading_rows)}
|
| 1171 |
+
|
| 1172 |
+
|
| 1173 |
+
_GOOGLE_ACTIVITY_TYPES: dict[str, str] = {
|
| 1174 |
+
"7": "cycling", "11": "cycling",
|
| 1175 |
+
"1": "other", "17": "hiking",
|
| 1176 |
+
"37": "running", "38": "running",
|
| 1177 |
+
"39": "running", "93": "running",
|
| 1178 |
+
"41": "other", "79": "walking",
|
| 1179 |
+
"80": "walking", "45": "other",
|
| 1180 |
+
"97": "weightlifting", "20": "other",
|
| 1181 |
+
"82": "swimming",
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
|
| 1185 |
+
def _google_fit_activity_type(code: str) -> str:
|
| 1186 |
+
return _GOOGLE_ACTIVITY_TYPES.get(str(code), _classify_activity(code))
|
backend/routes/user.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from auth import get_user_id
|
| 3 |
+
from db import get_db
|
| 4 |
+
from datetime import datetime, timezone, timedelta
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/me")
|
| 10 |
+
async def get_me(user_id: str = Depends(get_user_id)):
|
| 11 |
+
"""Return basic stats for the authenticated user."""
|
| 12 |
+
db = await get_db()
|
| 13 |
+
|
| 14 |
+
readings = await db.table("watch_data").select("id", count="exact").eq("user_id", user_id).eq("type", "reading").execute()
|
| 15 |
+
workouts = await db.table("watch_data").select("id", count="exact").eq("user_id", user_id).eq("type", "workout").execute()
|
| 16 |
+
analyses = await db.table("analyses").select("id", count="exact").eq("user_id", user_id).execute()
|
| 17 |
+
|
| 18 |
+
return {
|
| 19 |
+
"user_id": user_id,
|
| 20 |
+
"readings": readings.count,
|
| 21 |
+
"workouts": workouts.count,
|
| 22 |
+
"analyses": analyses.count,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/history")
|
| 27 |
+
async def get_history(user_id: str = Depends(get_user_id), limit: int = 20):
|
| 28 |
+
"""Return the user's past AI analyses, newest first — consumed by the phone."""
|
| 29 |
+
db = await get_db()
|
| 30 |
+
result = await (
|
| 31 |
+
db.table("analyses")
|
| 32 |
+
.select("*")
|
| 33 |
+
.eq("user_id", user_id)
|
| 34 |
+
.order("created_at", desc=True)
|
| 35 |
+
.limit(limit)
|
| 36 |
+
.execute()
|
| 37 |
+
)
|
| 38 |
+
return {"analyses": result.data}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("/summary")
|
| 42 |
+
async def get_summary(user_id: str = Depends(get_user_id)):
|
| 43 |
+
"""Weekly comparison stats, streak, and personal records."""
|
| 44 |
+
db = await get_db()
|
| 45 |
+
now = datetime.now(timezone.utc)
|
| 46 |
+
seven_ago = now - timedelta(days=7)
|
| 47 |
+
fourteen_ago = now - timedelta(days=14)
|
| 48 |
+
sixty_ago = now - timedelta(days=60)
|
| 49 |
+
|
| 50 |
+
# ── Last 14 days for week-over-week comparison ─────────────────────
|
| 51 |
+
recent = await (
|
| 52 |
+
db.table("watch_data").select("*")
|
| 53 |
+
.eq("user_id", user_id)
|
| 54 |
+
.gte("timestamp", fourteen_ago.isoformat())
|
| 55 |
+
.execute()
|
| 56 |
+
)
|
| 57 |
+
rows = recent.data or []
|
| 58 |
+
seven_str = seven_ago.isoformat()
|
| 59 |
+
this_week = [r for r in rows if (r.get("timestamp") or "") >= seven_str]
|
| 60 |
+
last_week = [r for r in rows if (r.get("timestamp") or "") < seven_str]
|
| 61 |
+
|
| 62 |
+
def week_stats(week_rows):
|
| 63 |
+
workouts = [r for r in week_rows if r.get("type") == "workout"]
|
| 64 |
+
readings = [r for r in week_rows if r.get("type") == "reading"]
|
| 65 |
+
hrs = [r.get("avg_heart_rate") or r.get("heart_rate") for r in week_rows
|
| 66 |
+
if r.get("avg_heart_rate") or r.get("heart_rate")]
|
| 67 |
+
steps = [r.get("steps") or 0 for r in readings]
|
| 68 |
+
sleep = [r.get("sleep_hours") for r in readings if r.get("sleep_hours")]
|
| 69 |
+
return {
|
| 70 |
+
"workouts": len(workouts),
|
| 71 |
+
"distance_km": round(sum(r.get("distance_meters") or 0 for r in workouts) / 1000, 1),
|
| 72 |
+
"calories": round(sum(r.get("calories_burned") or 0 for r in workouts)),
|
| 73 |
+
"avg_hr": round(sum(hrs) / len(hrs)) if hrs else 0,
|
| 74 |
+
"total_steps": sum(steps),
|
| 75 |
+
"avg_sleep": round(sum(sleep) / len(sleep), 1) if sleep else 0,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
# ── Streak (consecutive days with a workout) ───────────────────────
|
| 79 |
+
streak_res = await (
|
| 80 |
+
db.table("watch_data").select("timestamp")
|
| 81 |
+
.eq("user_id", user_id).eq("type", "workout")
|
| 82 |
+
.gte("timestamp", sixty_ago.isoformat())
|
| 83 |
+
.execute()
|
| 84 |
+
)
|
| 85 |
+
active_days = {(r.get("timestamp") or "")[:10] for r in (streak_res.data or []) if r.get("timestamp")}
|
| 86 |
+
streak, check = 0, now.date()
|
| 87 |
+
while str(check) in active_days:
|
| 88 |
+
streak += 1
|
| 89 |
+
check -= timedelta(days=1)
|
| 90 |
+
|
| 91 |
+
# ── Personal records ───────────────────────────────────────────────
|
| 92 |
+
w_all = await (db.table("watch_data").select("distance_meters,calories_burned,heart_rate,steps,timestamp")
|
| 93 |
+
.eq("user_id", user_id).execute())
|
| 94 |
+
r_all = await (db.table("routes").select("distance_meters,duration_seconds")
|
| 95 |
+
.eq("user_id", user_id).execute())
|
| 96 |
+
|
| 97 |
+
w_rows = w_all.data or []
|
| 98 |
+
workout_rows = [r for r in w_rows if r.get("distance_meters") or r.get("calories_burned")]
|
| 99 |
+
reading_rows = [r for r in w_rows if r.get("heart_rate") or r.get("steps")]
|
| 100 |
+
|
| 101 |
+
max_dist_m = max((r.get("distance_meters") or 0 for r in workout_rows), default=0)
|
| 102 |
+
max_cal = max((r.get("calories_burned") or 0 for r in workout_rows), default=0)
|
| 103 |
+
max_hr = max((r.get("heart_rate") or 0 for r in reading_rows), default=0)
|
| 104 |
+
|
| 105 |
+
steps_by_day: dict = {}
|
| 106 |
+
for r in reading_rows:
|
| 107 |
+
day = (r.get("timestamp") or "")[:10]
|
| 108 |
+
if day:
|
| 109 |
+
steps_by_day[day] = steps_by_day.get(day, 0) + (r.get("steps") or 0)
|
| 110 |
+
max_steps_day = max(steps_by_day.values(), default=0)
|
| 111 |
+
|
| 112 |
+
best_pace_str, best_pace_val = None, float("inf")
|
| 113 |
+
for r in (r_all.data or []):
|
| 114 |
+
dist, dur = r.get("distance_meters") or 0, r.get("duration_seconds") or 0
|
| 115 |
+
if dist > 500 and dur > 0:
|
| 116 |
+
pace = (dur / 60) / (dist / 1000)
|
| 117 |
+
if pace < best_pace_val:
|
| 118 |
+
best_pace_val = pace
|
| 119 |
+
m, s = int(pace), int((pace % 1) * 60)
|
| 120 |
+
best_pace_str = f"{m}:{s:02d}"
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"streak": streak,
|
| 124 |
+
"this_week": week_stats(this_week),
|
| 125 |
+
"last_week": week_stats(last_week),
|
| 126 |
+
"records": {
|
| 127 |
+
"longest_km": round(max_dist_m / 1000, 1) if max_dist_m else None,
|
| 128 |
+
"max_calories": round(max_cal) if max_cal else None,
|
| 129 |
+
"max_hr": round(max_hr) if max_hr else None,
|
| 130 |
+
"max_steps": max_steps_day if max_steps_day else None,
|
| 131 |
+
"best_pace": best_pace_str,
|
| 132 |
+
},
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@router.get("/data")
|
| 137 |
+
async def get_watch_data(user_id: str = Depends(get_user_id), limit: int = 100):
|
| 138 |
+
"""Return raw watch data for the user, newest first."""
|
| 139 |
+
db = await get_db()
|
| 140 |
+
result = await (
|
| 141 |
+
db.table("watch_data")
|
| 142 |
+
.select("*")
|
| 143 |
+
.eq("user_id", user_id)
|
| 144 |
+
.order("timestamp", desc=True)
|
| 145 |
+
.limit(limit)
|
| 146 |
+
.execute()
|
| 147 |
+
)
|
| 148 |
+
return {"data": result.data}
|
backend/routes/watch.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from auth import get_user_id
|
| 3 |
+
from db import get_db
|
| 4 |
+
from models.watch import WatchSyncPayload
|
| 5 |
+
from calories import estimate_calories
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.post("/sync")
|
| 11 |
+
async def sync_watch_data(payload: WatchSyncPayload, user_id: str = Depends(get_user_id)):
|
| 12 |
+
"""Receive a batch of watch readings and workouts from the phone/watch app."""
|
| 13 |
+
db = await get_db()
|
| 14 |
+
rows = []
|
| 15 |
+
|
| 16 |
+
for reading in payload.readings:
|
| 17 |
+
rows.append({
|
| 18 |
+
"user_id": user_id,
|
| 19 |
+
"type": "reading",
|
| 20 |
+
"device": payload.device,
|
| 21 |
+
**reading.model_dump(),
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
# The watch no longer computes calories (it can't know the user's weight), so
|
| 25 |
+
# we estimate them here. Only for watch-originated workouts — manual web
|
| 26 |
+
# entries intentionally leave calories blank unless the user types them.
|
| 27 |
+
is_watch = (payload.device or "").startswith("fitness_watch")
|
| 28 |
+
|
| 29 |
+
for workout in payload.workouts:
|
| 30 |
+
row = {
|
| 31 |
+
"user_id": user_id,
|
| 32 |
+
"type": "workout",
|
| 33 |
+
"device": payload.device,
|
| 34 |
+
**workout.model_dump(),
|
| 35 |
+
}
|
| 36 |
+
if is_watch and row.get("calories_burned") is None:
|
| 37 |
+
row["calories_burned"] = estimate_calories(
|
| 38 |
+
row.get("duration_minutes") or 0, row.get("workout_type"))
|
| 39 |
+
rows.append(row)
|
| 40 |
+
|
| 41 |
+
if not rows:
|
| 42 |
+
raise HTTPException(status_code=400, detail="No data provided")
|
| 43 |
+
|
| 44 |
+
for row in rows:
|
| 45 |
+
if row.get("timestamp"):
|
| 46 |
+
row["timestamp"] = row["timestamp"].isoformat()
|
| 47 |
+
|
| 48 |
+
await db.table("watch_data").insert(rows).execute()
|
| 49 |
+
return {"synced": len(rows)}
|