diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index 25b72ed3..d0457d7f 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -237,7 +237,7 @@ class StartupCoordinator: label="Polymarket 钱包异动监听(已停用)", configured_enabled=False, started=False, - reason="retired", + reason="retired_replaced_by_market_monitor", details={ "note": "wallet activity watcher retired", }, diff --git a/web/analysis_service.py b/web/analysis_service.py index 5e032c13..7468e5fc 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -1,8 +1,5 @@ from __future__ import annotations -import hashlib -import json -import os import re import time as _time import threading @@ -10,7 +7,6 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone, timedelta from typing import Dict, Any, Optional -import httpx from fastapi import HTTPException from loguru import logger @@ -23,7 +19,6 @@ from web.core import ( CITY_RISK_PROFILES, SETTLEMENT_SOURCE_LABELS, _is_excluded_model_name, - _market_layer, _sf, _weather, ) @@ -35,6 +30,19 @@ from src.data_collection.city_time import get_city_utc_offset_seconds from src.data_collection.nmc_sources import NMC_CITY_REFERENCES from src.database.runtime_state import IntradayPathSnapshotRepository from src.models.lgbm_daily_high import predict_lgbm_daily_high +from web.services.groq_commentary import ( + build_groq_commentary_context as _groq_context_builder, + clean_commentary_text as _groq_clean_text, + groq_commentary_enabled as _groq_enabled, + maybe_enrich_dynamic_commentary_with_groq as _groq_enrich, + normalize_groq_commentary_payload as _groq_normalize_payload, + request_groq_commentary as _groq_request, +) +from web.services.city_payloads import ( + build_city_detail_payload as _city_payload_detail, + build_city_market_scan_payload as _city_payload_market_scan, + build_city_summary_payload as _city_payload_summary, +) TURKISH_MGM_CITIES = {"ankara", "istanbul"} _ANALYSIS_CACHE_STATS_LOCK = threading.Lock() @@ -49,13 +57,6 @@ _ANALYSIS_CACHE_STATS: Dict[str, Any] = { } _SUMMARY_CACHE_LOCK = threading.Lock() _SUMMARY_CACHE: Dict[str, Dict[str, Any]] = {} -_GROQ_COMMENTARY_CACHE_LOCK = threading.Lock() -_GROQ_COMMENTARY_CACHE: Dict[str, Dict[str, Any]] = {} -_GROQ_COMMENTARY_CACHE_TTL_SEC = int( - os.getenv("POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC", "1800") -) - - def _dedupe_forecast_daily(rows: Any) -> list[Dict[str, Any]]: if not isinstance(rows, list): return [] @@ -424,206 +425,30 @@ def _set_cached_summary(city: str, payload: Dict[str, Any]) -> None: def _groq_commentary_enabled() -> bool: - enabled = str( - os.getenv("POLYWEATHER_GROQ_COMMENTARY_ENABLED", "false") - ).strip().lower() - api_key = str(os.getenv("GROQ_API_KEY") or "").strip() - return enabled in {"1", "true", "yes", "on"} and bool(api_key) + return _groq_enabled() def _clean_commentary_text(value: Any, *, limit: int = 240) -> str: - text = str(value or "").strip() - if not text: - return "" - text = re.sub(r"\s+", " ", text) - return text[:limit].strip() + return _groq_clean_text(value, limit=limit) def _build_groq_commentary_context(result: Dict[str, Any]) -> Dict[str, Any]: - dynamic = result.get("dynamic_commentary") or {} - vertical = result.get("vertical_profile_signal") or {} - taf_signal = ((result.get("taf") or {}).get("signal") or {}) if isinstance(result.get("taf"), dict) else {} - network = result.get("network_lead_signal") or {} - peak = result.get("peak") or {} - current = result.get("current") or {} - airport_primary = result.get("airport_primary") or {} - notes = dynamic.get("notes") if isinstance(dynamic.get("notes"), list) else [] - compact_notes = [_clean_commentary_text(item, limit=180) for item in notes] - compact_notes = [item for item in compact_notes if item][:4] - return { - "city": result.get("display_name") or result.get("name"), - "local_date": result.get("local_date"), - "local_time": result.get("local_time"), - "temp_symbol": result.get("temp_symbol"), - "current_temp": current.get("temp"), - "day_high_so_far": current.get("max_so_far"), - "airport_anchor_temp": airport_primary.get("temp"), - "airport_vs_network_delta": result.get("airport_vs_network_delta"), - "peak_hours": peak.get("hours") or [], - "peak_status": peak.get("status"), - "network_lead_status": network.get("status"), - "network_lead_note": _clean_commentary_text(network.get("note"), limit=180), - "rules_summary": _clean_commentary_text(dynamic.get("summary"), limit=260), - "rules_notes": compact_notes, - "upper_air_summary_zh": _clean_commentary_text(vertical.get("summary_zh"), limit=260), - "upper_air_summary_en": _clean_commentary_text(vertical.get("summary_en"), limit=260), - "taf_summary_zh": _clean_commentary_text(taf_signal.get("summary_zh"), limit=220), - "taf_summary_en": _clean_commentary_text(taf_signal.get("summary_en"), limit=220), - "taf_peak_window": _clean_commentary_text(taf_signal.get("peak_window"), limit=80), - } + return _groq_context_builder(result) def _normalize_groq_commentary_payload(payload: Dict[str, Any]) -> Dict[str, Any]: - def _headline(value: Any, fallback: str) -> str: - text = _clean_commentary_text(value, limit=90) - return text or fallback - - def _bullets(value: Any) -> list[str]: - items = value if isinstance(value, list) else [] - cleaned = [_clean_commentary_text(item, limit=120) for item in items] - cleaned = [item for item in cleaned if item] - return cleaned[:3] - - zh_headline = _headline(payload.get("headline_zh"), "结构信号以现有规则结论为主。") - en_headline = _headline(payload.get("headline_en"), "Structural read stays anchored to the existing rule-based signal.") - zh_bullets = _bullets(payload.get("bullets_zh")) - en_bullets = _bullets(payload.get("bullets_en")) - while len(zh_bullets) < 3: - zh_bullets.append("继续结合当前节奏、边界风险和峰值窗口判断。") - while len(en_bullets) < 3: - en_bullets.append("Keep the read anchored to pace, boundary risk, and the peak window.") - return { - "headline_zh": zh_headline, - "headline_en": en_headline, - "bullets_zh": zh_bullets[:3], - "bullets_en": en_bullets[:3], - "source": "groq", - } + return _groq_normalize_payload(payload) def _request_groq_commentary(context: Dict[str, Any]) -> Optional[Dict[str, Any]]: - api_key = str(os.getenv("GROQ_API_KEY") or "").strip() - if not api_key: - return None - model = str(os.getenv("POLYWEATHER_GROQ_COMMENTARY_MODEL") or "openai/gpt-oss-20b").strip() - timeout_sec = float(os.getenv("POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC", "8")) - payload = { - "model": model, - "temperature": 0.2, - "max_tokens": 400, - "messages": [ - { - "role": "system", - "content": ( - "You rewrite weather-market structure commentary. " - "Never invent facts. Use only the provided context. " - "Return concise bilingual output for a dashboard: " - "one headline and exactly three bullets in Chinese, and the same in English. " - "Keep every bullet actionable and short." - ), - }, - { - "role": "user", - "content": json.dumps(context, ensure_ascii=False), - }, - ], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "polyweather_structure_commentary", - "strict": True, - "schema": { - "type": "object", - "additionalProperties": False, - "properties": { - "headline_zh": {"type": "string"}, - "bullets_zh": { - "type": "array", - "items": {"type": "string"}, - "minItems": 3, - "maxItems": 3, - }, - "headline_en": {"type": "string"}, - "bullets_en": { - "type": "array", - "items": {"type": "string"}, - "minItems": 3, - "maxItems": 3, - }, - }, - "required": [ - "headline_zh", - "bullets_zh", - "headline_en", - "bullets_en", - ], - }, - }, - }, - } - with httpx.Client(timeout=timeout_sec) as client: - response = client.post( - "https://api.groq.com/openai/v1/chat/completions", - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - json=payload, - ) - response.raise_for_status() - body = response.json() - content = ( - (((body.get("choices") or [{}])[0]).get("message") or {}).get("content") - if isinstance(body, dict) - else None - ) - if not content: - return None - try: - return _normalize_groq_commentary_payload(json.loads(str(content))) - except Exception: - logger.warning("Groq commentary returned non-JSON payload") - return None + return _groq_request(context) def _maybe_enrich_dynamic_commentary_with_groq( city: str, result: Dict[str, Any], ) -> Dict[str, Any]: - dynamic = result.get("dynamic_commentary") or {} - if not _groq_commentary_enabled(): - return dynamic - if dynamic.get("headline_zh") and dynamic.get("bullets_zh"): - return dynamic - - context = _build_groq_commentary_context(result) - if not context.get("rules_summary") and not context.get("rules_notes"): - return dynamic - - cache_key = hashlib.sha256( - json.dumps({"city": city, "context": context}, sort_keys=True, ensure_ascii=False).encode("utf-8") - ).hexdigest() - now = _time.time() - with _GROQ_COMMENTARY_CACHE_LOCK: - cached = _GROQ_COMMENTARY_CACHE.get(cache_key) - if cached and now - float(cached.get("t") or 0) < _GROQ_COMMENTARY_CACHE_TTL_SEC: - merged = dict(dynamic) - merged.update(cached.get("payload") or {}) - return merged - - try: - enriched = _request_groq_commentary(context) - except Exception as exc: - logger.warning("Groq commentary skipped for {}: {}", city, exc) - return dynamic - if not enriched: - return dynamic - - with _GROQ_COMMENTARY_CACHE_LOCK: - _GROQ_COMMENTARY_CACHE[cache_key] = {"t": now, "payload": enriched} - merged = dict(dynamic) - merged.update(enriched) - return merged + return _groq_enrich(city, result) def _interpolate_hourly_value( @@ -3118,27 +2943,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: - return { - "name": data.get("name"), - "display_name": data.get("display_name"), - "icao": data.get("risk", {}).get("icao"), - "utc_offset_seconds": data.get("utc_offset_seconds"), - "local_time": data.get("local_time"), - "temp_symbol": data.get("temp_symbol"), - "current": { - "temp": data.get("current", {}).get("temp"), - "obs_time": data.get("current", {}).get("obs_time"), - "settlement_source": data.get("current", {}).get("settlement_source"), - "settlement_source_label": data.get("current", {}).get("settlement_source_label"), - }, - "deb": {"prediction": data.get("deb", {}).get("prediction")}, - "deviation_monitor": data.get("deviation_monitor") or {}, - "risk": { - "level": data.get("risk", {}).get("level"), - "warning": data.get("risk", {}).get("warning"), - }, - "updated_at": data.get("updated_at"), - } + return _city_payload_summary(data) def _build_city_market_scan_payload( @@ -3148,137 +2953,13 @@ def _build_city_market_scan_payload( lite: bool = False, scan_filters: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: - city = str(data.get("name") or "").strip().lower() - local_date = str(data.get("local_date") or "").strip() - requested_date = str(target_date or "").strip() - selected_date = requested_date or local_date - - multi_model_daily = data.get("multi_model_daily") or {} - selected_daily = ( - multi_model_daily.get(selected_date) - if isinstance(multi_model_daily, dict) - else None - ) - if not isinstance(selected_daily, dict): - selected_daily = {} - selected_date = local_date - - distribution = selected_daily.get("probabilities") - if not isinstance(distribution, list) or not distribution: - distribution = data.get("probabilities", {}).get("distribution", []) or [] - distribution_all = selected_daily.get("probabilities_all") - if not isinstance(distribution_all, list) or not distribution_all: - distribution_all = data.get("probabilities", {}).get("distribution_all", []) or [] - if not distribution_all: - distribution_all = distribution - - model_map = selected_daily.get("models") or data.get("multi_model") or {} - if not isinstance(model_map, dict): - model_map = {} - - anchor_temp = None - anchor_model = None - for model_name, raw_value in model_map.items(): - value = _sf(raw_value) - if value is None: - continue - if anchor_temp is None or value > anchor_temp: - anchor_temp = value - anchor_model = str(model_name or "").strip() or None - - anchor_temp_c = anchor_temp - temp_symbol = str(data.get("temp_symbol") or "") - if anchor_temp_c is not None and "F" in temp_symbol.upper(): - anchor_temp_c = (anchor_temp_c - 32.0) * 5.0 / 9.0 - anchor_settlement = apply_city_settlement(city, anchor_temp_c) if anchor_temp_c is not None else None - - primary_bucket = None - if isinstance(distribution, list) and distribution: - ranked_buckets = [] - temp_symbol_upper = str(temp_symbol or "").upper() - max_primary_bucket_delta = 16.0 if "F" in temp_symbol_upper else 8.0 - for idx, row in enumerate(distribution_all): - if not isinstance(row, dict): - continue - bucket_value = _sf( - row.get("temp") - if row.get("temp") is not None - else row.get("value") - if row.get("value") is not None - else row.get("lower") - ) - if ( - anchor_temp is not None - and bucket_value is not None - and abs(float(bucket_value) - float(anchor_temp)) > max_primary_bucket_delta - ): - continue - bucket_prob = _sf(row.get("probability")) - prob_rank = bucket_prob if bucket_prob is not None else -1.0 - ranked_buckets.append((-prob_rank, idx, row)) - if ranked_buckets: - ranked_buckets.sort(key=lambda x: (x[0], x[1])) - primary_bucket = ranked_buckets[0][2] - elif anchor_temp is None: - primary_bucket = distribution[0] - - model_probability = None - if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None: - try: - raw_probability = float(primary_bucket.get("probability")) - model_probability = raw_probability / 100.0 if raw_probability > 1.0 else raw_probability - except Exception: - model_probability = None - - fallback_sparkline = [ - p.get("probability", 0) - for p in distribution_all[:8] - if isinstance(p, dict) - ] - current = data.get("current") or {} - selected_deb = selected_daily.get("deb") if isinstance(selected_daily.get("deb"), dict) else {} - current_deb = data.get("deb") if isinstance(data.get("deb"), dict) else {} - scan_context = { - "local_date": data.get("local_date"), - "local_time": data.get("local_time"), - "peak": data.get("peak") or {}, - "current_max_so_far": current.get("max_so_far"), - "current_temp": current.get("temp"), - "trend": data.get("trend") or {}, - "network_lead_signal": data.get("network_lead_signal") or {}, - "models": model_map, - "deb_prediction": selected_deb.get("prediction") or current_deb.get("prediction"), - } - market_scan = _market_layer.build_market_scan( - city=data.get("name"), - target_date=selected_date or data.get("local_date"), - temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None, - model_probability=model_probability, - probability_distribution=distribution_all, - temp_symbol=temp_symbol, - fallback_sparkline=fallback_sparkline, - forced_market_slug=market_slug, - include_related_buckets=not lite, + return _city_payload_market_scan( + data, + market_slug=market_slug, + target_date=target_date, + lite=lite, scan_filters=scan_filters, - scan_context=scan_context, ) - if isinstance(market_scan, dict): - market_scan["anchor_model"] = anchor_model - market_scan["anchor_high"] = anchor_temp - market_scan["anchor_settlement"] = anchor_settlement - market_scan["open_meteo_settlement"] = anchor_settlement - probabilities = data.get("probabilities") or {} - market_scan["probability_engine"] = str( - probabilities.get("engine") or "legacy" - ).strip() or "legacy" - market_scan["probability_calibration_mode"] = str( - probabilities.get("calibration_mode") or "legacy" - ).strip() or "legacy" - return { - "market_scan": market_scan, - "selected_date": selected_date or data.get("local_date"), - "fetched_at": data.get("updated_at"), - } def _build_city_detail_payload( @@ -3286,96 +2967,12 @@ def _build_city_detail_payload( market_slug: Optional[str] = None, target_date: Optional[str] = None, ) -> Dict[str, Any]: - market_payload = _build_city_market_scan_payload( + return _city_payload_detail( data, market_slug=market_slug, target_date=target_date, ) - market_scan = market_payload.get("market_scan") - return { - "city": data.get("name"), - "fetched_at": data.get("updated_at"), - "overview": { - "name": data.get("name"), - "display_name": data.get("display_name"), - "icao": data.get("risk", {}).get("icao"), - "airport": data.get("risk", {}).get("airport"), - "lat": data.get("lat"), - "lon": data.get("lon"), - "local_time": data.get("local_time"), - "local_date": data.get("local_date"), - "temp_symbol": data.get("temp_symbol"), - "current_temp": data.get("current", {}).get("temp"), - "settlement_source": data.get("current", {}).get("settlement_source"), - "settlement_source_label": data.get("current", {}).get("settlement_source_label"), - "settlement_station": data.get("settlement_station") or {}, - "deb_prediction": data.get("deb", {}).get("prediction"), - "risk_level": data.get("risk", {}).get("level"), - "risk_warning": data.get("risk", {}).get("warning"), - "updated_at": data.get("updated_at"), - }, - "official": { - "available": bool(data.get("current", {}).get("temp") is not None), - "metar": { - "observation_time": data.get("airport_current", {}).get("obs_time"), - "obs_age_min": data.get("airport_current", {}).get("obs_age_min"), - "report_time": data.get("airport_current", {}).get("report_time"), - "receipt_time": data.get("airport_current", {}).get("receipt_time"), - "raw_metar": data.get("airport_current", {}).get("raw_metar"), - "current": data.get("airport_current") or {}, - }, - "taf": data.get("taf") or {}, - "weather_gov": {}, - "mgm": data.get("mgm") or {}, - "mgm_nearby": data.get("mgm_nearby") or [], - "nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"), - "airport_primary": data.get("airport_primary") or {}, - "airport_primary_today_obs": data.get("airport_primary_today_obs") or [], - "official_nearby": data.get("official_nearby") or [], - "official_network_source": data.get("official_network_source"), - "official_network_status": data.get("official_network_status") or {}, - "network_lead_signal": data.get("network_lead_signal") or {}, - "network_spread_signal": data.get("network_spread_signal") or {}, - "center_station_candidate": data.get("center_station_candidate"), - "airport_vs_network_delta": data.get("airport_vs_network_delta"), - }, - "timeseries": { - "metar_recent_obs": data.get("metar_recent_obs") or [], - "metar_today_obs": data.get("metar_today_obs") or [], - "settlement_today_obs": data.get("settlement_today_obs") or [], - "hourly": data.get("hourly") or {}, - "mgm_hourly": (data.get("mgm") or {}).get("hourly", []), - "forecast_daily": (data.get("forecast") or {}).get("daily", []), - }, - "models": { - k: v - for k, v in (data.get("multi_model") or {}).items() - if not _is_excluded_model_name(k) - }, - "deb": data.get("deb") or {}, - "multi_model_daily": data.get("multi_model_daily") or {}, - "probabilities": data.get("probabilities") or {"mu": None, "distribution": []}, - "dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []}, - "intraday_meteorology": data.get("intraday_meteorology") - or _build_intraday_meteorology(data), - "vertical_profile_signal": data.get("vertical_profile_signal") or {}, - "taf": data.get("taf") or {}, - "market_scan": market_scan, - "risk": data.get("risk"), - "settlement_station": data.get("settlement_station") or {}, - "airport_primary": data.get("airport_primary") or {}, - "official_nearby": data.get("official_nearby") or [], - "official_network_source": data.get("official_network_source"), - "official_network_status": data.get("official_network_status") or {}, - "network_lead_signal": data.get("network_lead_signal") or {}, - "network_spread_signal": data.get("network_spread_signal") or {}, - "center_station_candidate": data.get("center_station_candidate"), - "airport_vs_network_delta": data.get("airport_vs_network_delta"), - "airport_current": data.get("airport_current") or {}, - "nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"), - "ai_analysis": data.get("ai_analysis") or "", - "errors": {}, - } + # ────────────────────────────────────────────────────────── diff --git a/web/app.py b/web/app.py index 3914a87b..070a026b 100644 --- a/web/app.py +++ b/web/app.py @@ -20,10 +20,9 @@ from web.analysis_service import ( # noqa: E402 _build_city_detail_payload, _build_city_summary_payload, ) -from web.core import app # noqa: E402 -from web.routes import router # noqa: E402 +from web.app_factory import create_app # noqa: E402 -app.include_router(router) +app = create_app() __all__ = [ "app", diff --git a/web/app_factory.py b/web/app_factory.py new file mode 100644 index 00000000..3f141c96 --- /dev/null +++ b/web/app_factory.py @@ -0,0 +1,35 @@ +"""Application assembly for the PolyWeather FastAPI backend. + +This module centralizes router registration while preserving the existing +``web.core.app`` singleton and middleware setup during the transition toward a +more modular backend structure. +""" + +from fastapi import FastAPI + +from web.core import app as core_app +from web.routers.analytics import router as analytics_router +from web.routers.city import router as city_router +from web.routers.auth import router as auth_router +from web.routers.ops import router as ops_router +from web.routers.payments import router as payments_router +from web.routers.scan import router as scan_router +from web.routers.system import router as system_router +from web.routes import router as legacy_router + +_ROUTES_REGISTERED_FLAG = "_polyweather_routes_registered" + + +def create_app() -> FastAPI: + """Return the configured FastAPI app with routers registered once.""" + if not bool(getattr(core_app.state, _ROUTES_REGISTERED_FLAG, False)): + core_app.include_router(system_router) + core_app.include_router(city_router) + core_app.include_router(auth_router) + core_app.include_router(analytics_router) + core_app.include_router(scan_router) + core_app.include_router(payments_router) + core_app.include_router(ops_router) + core_app.include_router(legacy_router) + setattr(core_app.state, _ROUTES_REGISTERED_FLAG, True) + return core_app diff --git a/web/routers/__init__.py b/web/routers/__init__.py new file mode 100644 index 00000000..64d1f59d --- /dev/null +++ b/web/routers/__init__.py @@ -0,0 +1 @@ +"""Router package for grouped FastAPI route modules.""" diff --git a/web/routers/analytics.py b/web/routers/analytics.py new file mode 100644 index 00000000..b2b8a143 --- /dev/null +++ b/web/routers/analytics.py @@ -0,0 +1,13 @@ +"""Analytics event API routes.""" + +from fastapi import APIRouter, Request + +from web.core import AnalyticsEventRequest +from web.services.analytics_api import track_analytics_event + +router = APIRouter(tags=["analytics"]) + + +@router.post("/api/analytics/events") +async def analytics_track(request: Request, body: AnalyticsEventRequest): + return track_analytics_event(request, body) diff --git a/web/routers/auth.py b/web/routers/auth.py new file mode 100644 index 00000000..7e6d83de --- /dev/null +++ b/web/routers/auth.py @@ -0,0 +1,12 @@ +"""Authentication API routes.""" + +from fastapi import APIRouter, Request + +from web.services.auth_api import get_auth_me_payload + +router = APIRouter(tags=["auth"]) + + +@router.get("/api/auth/me") +async def auth_me(request: Request): + return get_auth_me_payload(request) diff --git a/web/routers/city.py b/web/routers/city.py new file mode 100644 index 00000000..b5c0f494 --- /dev/null +++ b/web/routers/city.py @@ -0,0 +1,103 @@ +"""City and city-analysis API routes.""" + +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Request + +from web.services.city_api import ( + get_city_detail_aggregate_payload, + get_city_detail_payload, + get_city_history_payload, + get_city_market_scan_payload, + get_city_summary_payload, + list_cities_payload, +) + +router = APIRouter(tags=["city"]) + + +@router.get("/api/cities") +async def list_cities(request: Request): + return await list_cities_payload(request) + + +@router.get("/api/city/{name}") +async def city_detail( + request: Request, + background_tasks: BackgroundTasks, + name: str, + force_refresh: bool = False, + depth: str = "panel", +): + return await get_city_detail_payload( + request, + name, + force_refresh=force_refresh, + depth=depth, + ) + + +@router.get("/api/history/{name}") +async def city_history( + request: Request, + background_tasks: BackgroundTasks, + name: str, + include_records: bool = False, +): + return await get_city_history_payload( + request, + name, + include_records=include_records, + ) + + +@router.get("/api/city/{name}/summary") +async def city_summary( + request: Request, + background_tasks: BackgroundTasks, + name: str, + force_refresh: bool = False, +): + return await get_city_summary_payload( + request, + name, + force_refresh=force_refresh, + ) + + +@router.get("/api/city/{name}/detail") +async def city_detail_aggregate( + request: Request, + name: str, + force_refresh: bool = False, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, +): + return await get_city_detail_aggregate_payload( + request, + name, + force_refresh=force_refresh, + market_slug=market_slug, + target_date=target_date, + ) + + +@router.get("/api/city/{name}/market-scan") +async def city_market_scan( + request: Request, + background_tasks: BackgroundTasks, + name: str, + force_refresh: bool = False, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, + lite: bool = False, +): + return await get_city_market_scan_payload( + request, + background_tasks, + name, + force_refresh=force_refresh, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) diff --git a/web/routers/ops.py b/web/routers/ops.py new file mode 100644 index 00000000..40f40d99 --- /dev/null +++ b/web/routers/ops.py @@ -0,0 +1,79 @@ +"""Operations/admin API routes.""" + +from fastapi import APIRouter, Request + +from web.core import GrantPointsRequest +from web.services.ops_api import ( + get_ops_analytics_funnel, + get_ops_truth_history, + get_ops_weekly_leaderboard, + grant_ops_points, + list_ops_memberships, + list_ops_payment_incidents, + resolve_ops_payment_incident, + search_ops_users, +) + +router = APIRouter(tags=["ops"]) + + +@router.get("/api/ops/users") +async def ops_search_users(request: Request, q: str = "", limit: int = 20): + return search_ops_users(request, q=q, limit=limit) + + +@router.get("/api/ops/leaderboard/weekly") +async def ops_weekly_leaderboard(request: Request, limit: int = 20): + return get_ops_weekly_leaderboard(request, limit=limit) + + +@router.get("/api/ops/memberships") +async def ops_memberships(request: Request, limit: int = 200): + return list_ops_memberships(request, limit=limit) + + +@router.get("/api/ops/payments/incidents") +async def ops_payment_incidents( + request: Request, + limit: int = 50, + reason: str = "", + include_resolved: bool = False, +): + return list_ops_payment_incidents( + request, + limit=limit, + reason=reason, + include_resolved=include_resolved, + ) + + +@router.post("/api/ops/payments/incidents/{event_id}/resolve") +async def ops_resolve_payment_incident(request: Request, event_id: int): + return resolve_ops_payment_incident(request, event_id) + + +@router.post("/api/ops/users/grant-points") +async def ops_grant_points(request: Request, body: GrantPointsRequest): + return grant_ops_points(request, body) + + +@router.get("/api/ops/analytics/funnel") +async def ops_analytics_funnel(request: Request, days: int = 30): + return get_ops_analytics_funnel(request, days=days) + + +@router.get("/api/ops/truth-history") +async def ops_truth_history( + request: Request, + city: str = "", + date_from: str = "", + date_to: str = "", + limit: int = 200, +): + return get_ops_truth_history( + request, + city=city, + date_from=date_from, + date_to=date_to, + limit=limit, + ) diff --git a/web/routers/payments.py b/web/routers/payments.py new file mode 100644 index 00000000..6f55ed36 --- /dev/null +++ b/web/routers/payments.py @@ -0,0 +1,90 @@ +"""Payment API routes.""" + +from fastapi import APIRouter, Request + +from web.core import ( + ConfirmPaymentTxRequest, + CreatePaymentIntentRequest, + SubmitPaymentTxRequest, + WalletChallengeRequest, + WalletUnbindRequest, + WalletVerifyRequest, +) +from web.services.payment_api import ( + confirm_payment_tx as confirm_payment_tx_service, + create_payment_intent as create_payment_intent_service, + create_payment_wallet_challenge, + get_payment_config, + get_payment_intent, + get_payment_runtime, + list_payment_wallets, + reconcile_latest_payment, + submit_payment_tx as submit_payment_tx_service, + unbind_payment_wallet, + verify_payment_wallet, +) + +router = APIRouter(tags=["payments"]) + + +@router.get("/api/payments/config") +async def payment_config(request: Request): + return get_payment_config(request) + + +@router.get("/api/payments/runtime") +async def payment_runtime(request: Request): + return get_payment_runtime(request) + + +@router.get("/api/payments/wallets") +async def payment_wallets(request: Request): + return list_payment_wallets(request) + + +@router.delete("/api/payments/wallets") +async def payment_wallet_unbind(request: Request, body: WalletUnbindRequest): + return unbind_payment_wallet(request, body) + + +@router.post("/api/payments/wallets/challenge") +async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest): + return create_payment_wallet_challenge(request, body) + + +@router.post("/api/payments/wallets/verify") +async def payment_wallet_verify(request: Request, body: WalletVerifyRequest): + return verify_payment_wallet(request, body) + + +@router.post("/api/payments/intents") +async def payment_create_intent(request: Request, body: CreatePaymentIntentRequest): + return create_payment_intent_service(request, body) + + +@router.get("/api/payments/intents/{intent_id}") +async def payment_get_intent(request: Request, intent_id: str): + return get_payment_intent(request, intent_id) + + +@router.post("/api/payments/intents/{intent_id}/submit") +async def payment_submit_tx( + request: Request, + intent_id: str, + body: SubmitPaymentTxRequest, +): + return submit_payment_tx_service(request, intent_id, body) + + +@router.post("/api/payments/intents/{intent_id}/confirm") +async def payment_confirm_tx( + request: Request, + intent_id: str, + body: ConfirmPaymentTxRequest, +): + return confirm_payment_tx_service(request, intent_id, body) + + +@router.post("/api/payments/reconcile-latest") +async def payment_reconcile_latest(request: Request): + return reconcile_latest_payment(request) diff --git a/web/routers/scan.py b/web/routers/scan.py new file mode 100644 index 00000000..968d98b6 --- /dev/null +++ b/web/routers/scan.py @@ -0,0 +1,56 @@ +"""Market scan and scan AI API routes.""" + +from fastapi import APIRouter, Request + +from web.services.scan_api import ( + get_scan_city_ai_forecast_payload, + get_scan_city_ai_stream_response, + get_scan_terminal_ai_payload, + get_scan_terminal_payload, +) + +router = APIRouter(tags=["scan"]) + + +@router.get("/api/scan/terminal") +async def scan_terminal( + request: Request, + scan_mode: str = "tradable", + min_price: float = 0.05, + max_price: float = 0.95, + min_edge_pct: float = 2.0, + min_liquidity: float = 500.0, + high_liquidity_only: bool = False, + market_type: str = "maxtemp", + time_range: str = "today", + limit: int = 25, + force_refresh: bool = False, +): + return await get_scan_terminal_payload( + request, + scan_mode=scan_mode, + min_price=min_price, + max_price=max_price, + min_edge_pct=min_edge_pct, + min_liquidity=min_liquidity, + high_liquidity_only=high_liquidity_only, + market_type=market_type, + time_range=time_range, + limit=limit, + force_refresh=force_refresh, + ) + + +@router.post("/api/scan/terminal/ai") +async def scan_terminal_ai(request: Request): + return await get_scan_terminal_ai_payload(request) + + +@router.post("/api/scan/terminal/ai-city") +async def scan_terminal_ai_city(request: Request): + return await get_scan_city_ai_forecast_payload(request) + + +@router.post("/api/scan/terminal/ai-city/stream") +async def scan_terminal_ai_city_stream(request: Request): + return await get_scan_city_ai_stream_response(request) diff --git a/web/routers/system.py b/web/routers/system.py new file mode 100644 index 00000000..1075b578 --- /dev/null +++ b/web/routers/system.py @@ -0,0 +1,63 @@ +"""System and observability API routes for PolyWeather.""" + +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Request +from fastapi.responses import PlainTextResponse + +from web.services.system_api import ( + get_health_payload, + get_prometheus_metrics_response, + get_system_cache_status, + get_system_status_payload, + run_system_prewarm, + run_system_priority_warm, +) + +router = APIRouter(tags=["system"]) + + +@router.get("/healthz") +async def healthz(): + return get_health_payload() + + +@router.get("/api/system/status") +async def system_status(): + return await get_system_status_payload() + + +@router.post("/api/system/prewarm") +async def system_prewarm( + request: Request, + cities: Optional[str] = None, + force_refresh: bool = False, + include_detail: bool = False, + include_market: bool = False, +): + return run_system_prewarm( + request, + cities=cities, + force_refresh=force_refresh, + include_detail=include_detail, + include_market=include_market, + ) + + +@router.get("/api/system/cache-status") +async def system_cache_status(request: Request, cities: Optional[str] = None): + return get_system_cache_status(request, cities=cities) + + +@router.post("/api/system/priority-warm") +async def system_priority_warm( + request: Request, + background_tasks: BackgroundTasks, + timezone: Optional[str] = None, +): + return run_system_priority_warm(request, background_tasks, timezone=timezone) + + +@router.get("/metrics", response_class=PlainTextResponse) +async def metrics(): + return get_prometheus_metrics_response() diff --git a/web/routes.py b/web/routes.py index 70fea668..0e21bdbf 100644 --- a/web/routes.py +++ b/web/routes.py @@ -1,1814 +1,14 @@ -from __future__ import annotations +"""Compatibility facade for legacy ``web.routes`` imports. -import os -import time -from datetime import datetime, timedelta -from typing import Optional +Endpoint handlers now live under ``web.routers``. The remaining cache/history +helpers are implemented in ``web.services.city_runtime`` and re-exported here so +existing tests, monkeypatches, and transitional routers can keep importing +``web.routes`` while the backend is modularized incrementally. +""" -from fastapi.concurrency import run_in_threadpool -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request -from fastapi.responses import PlainTextResponse, StreamingResponse -from loguru import logger +from web.services import city_runtime as _city_runtime -from src.analysis.deb_algorithm import load_history -from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day -from src.database.db_manager import DBManager -from src.database.runtime_state import ( - DailyRecordRepository, - STATE_STORAGE_SQLITE, - TrainingFeatureRecordRepository, - TruthRecordRepository, - get_state_storage_mode, -) -from src.analysis.settlement_rounding import apply_city_settlement -from src.data_collection.country_networks import get_country_network_provider -from src.data_collection.city_registry import ALIASES -from src.data_collection.city_time import get_city_utc_offset_seconds -from src.utils.metrics import export_prometheus_metrics -from web.analysis_service import ( - _analyze, - _analyze_summary, - _build_city_detail_payload, - _build_city_market_scan_payload, - _build_city_summary_payload, -) -from web.scan_terminal_service import ( - build_scan_city_ai_forecast_payload, - build_scan_terminal_ai_payload, - build_scan_terminal_payload, - stream_scan_city_ai_forecast_payload, -) -from web.core import ( - AnalyticsEventRequest, - CITIES, - CITY_REGISTRY, - CITY_RISK_PROFILES, - PAYMENT_CHECKOUT, - PaymentCheckoutError, - SETTLEMENT_SOURCE_LABELS, - SUPABASE_ENTITLEMENT, - ConfirmPaymentTxRequest, - CreatePaymentIntentRequest, - GrantPointsRequest, - SubmitPaymentTxRequest, - WalletChallengeRequest, - WalletUnbindRequest, - WalletVerifyRequest, - _ENTITLEMENT_GUARD_ENABLED, - _SUPABASE_AUTH_REQUIRED, - _assert_entitlement, - _bind_optional_supabase_identity, - _require_ops_admin, - build_health_payload, - build_system_status_payload, - _require_supabase_identity, - _resolve_auth_points, - _resolve_weekly_profile, - _sf, - _is_excluded_model_name, -) +for _name in _city_runtime.__all__: + globals()[_name] = getattr(_city_runtime, _name) -router = APIRouter() -_CACHE_DB = DBManager() - -_DEB_RECENT_LOOKBACK = 7 -_DEB_RECENT_MIN_SAMPLES = 3 -_daily_record_repo = DailyRecordRepository() -_truth_record_repo = TruthRecordRepository() -_training_feature_repo = TrainingFeatureRecordRepository() - -TRACKABLE_ANALYTICS_EVENTS = { - "signup_completed", - "dashboard_active", - "paywall_feature_clicked", - "paywall_viewed", - "checkout_started", - "checkout_succeeded", -} - -DEFAULT_PREWARM_CITIES = [ - "ankara", - "istanbul", - "shanghai", - "beijing", - "shenzhen", - "guangzhou", - "qingdao", - "wuhan", - "chengdu", - "chongqing", - "hong kong", - "taipei", - "singapore", - "tokyo", - "seoul", - "busan", - "london", - "paris", - "madrid", -] -HISTORY_PREVIEW_DAY_LIMIT = 21 -ASIA_CORE_CITIES = [ - "hong kong", - "taipei", - "tokyo", - "seoul", - "busan", - "shanghai", - "beijing", - "guangzhou", - "qingdao", - "shenzhen", - "chongqing", - "chengdu", - "singapore", - "kuala lumpur", - "jakarta", -] -EUROPE_CORE_CITIES = [ - "istanbul", - "ankara", - "moscow", - "tel aviv", - "london", - "paris", - "madrid", - "milan", - "warsaw", - "amsterdam", - "helsinki", -] -US_CORE_CITIES = [ - "new york", - "los angeles", - "san francisco", - "austin", - "houston", - "chicago", - "dallas", - "miami", - "atlanta", - "seattle", -] -CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "1800"))) -CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "1800"))) -CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "1800"))) -CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "1800"))) -MARKET_SCAN_PAYLOAD_TTL_SEC = max( - 5, - int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")), -) -CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max( - 60, - int(os.getenv("POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC", "1800")), -) -CACHE_REFRESH_LOCK_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CACHE_REFRESH_LOCK_TTL_SEC", "120"))) - - -def _city_cache_is_fresh(entry: Optional[dict], ttl_sec: int) -> bool: - if not isinstance(entry, dict): - return False - updated_at_ts = float(entry.get("updated_at_ts") or 0.0) - if updated_at_ts <= 0: - return False - return (time.time() - updated_at_ts) < float(ttl_sec) - - -def _market_analysis_cache_is_fresh(entry: Optional[dict]) -> bool: - if not isinstance(entry, dict): - return False - payload = entry.get("payload") or {} - if isinstance(payload, dict): - cached_at_ts = float(payload.get("market_analysis_cached_at_ts") or 0.0) - if cached_at_ts > 0: - return (time.time() - cached_at_ts) < float(CITY_MARKET_CACHE_TTL_SEC) - return _city_cache_is_fresh(entry, CITY_MARKET_CACHE_TTL_SEC) - - -def _market_scan_cache_key( - data: dict, - market_slug: Optional[str] = None, - target_date: Optional[str] = None, - lite: bool = False, -) -> str: - local_date = str(data.get("local_date") or "").strip() - requested_date = str(target_date or "").strip() - selected_date = requested_date or local_date - multi_model_daily = data.get("multi_model_daily") or {} - if requested_date and isinstance(multi_model_daily, dict) and requested_date not in multi_model_daily: - selected_date = local_date - normalized_slug = str(market_slug or "").strip().lower() - return f"{selected_date}|{normalized_slug}|lite={1 if lite else 0}" - - -def _attach_market_scan_payload( - payload: dict, - *, - market_slug: Optional[str] = None, - target_date: Optional[str] = None, - lite: bool = False, -) -> dict: - if not isinstance(payload, dict): - return payload - scan_payload = _build_city_market_scan_payload( - payload, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - now_ts = time.time() - payload["market_scan_payload"] = scan_payload - payload["market_scan_updated_at"] = datetime.now().isoformat() - payload["market_scan_updated_at_ts"] = now_ts - payload["market_scan_cache_key"] = _market_scan_cache_key( - payload, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - return payload - - -def _get_cached_market_scan_payload( - payload: dict, - *, - market_slug: Optional[str] = None, - target_date: Optional[str] = None, - lite: bool = False, -) -> Optional[dict]: - if not isinstance(payload, dict): - return None - scan_payload = payload.get("market_scan_payload") - if not isinstance(scan_payload, dict): - return None - expected_key = _market_scan_cache_key( - payload, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - cached_key = str(payload.get("market_scan_cache_key") or "") - if cached_key != expected_key: - return None - updated_at_ts = float(payload.get("market_scan_updated_at_ts") or 0.0) - if updated_at_ts <= 0: - return None - if (time.time() - updated_at_ts) >= float(MARKET_SCAN_PAYLOAD_TTL_SEC): - return None - return scan_payload - - -def _refresh_market_scan_payload_from_cached_analysis( - city: str, - payload: dict, - *, - market_slug: Optional[str] = None, - target_date: Optional[str] = None, - lite: bool = False, -) -> dict: - _attach_market_scan_payload( - payload, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - _CACHE_DB.set_city_cache( - "market", - city, - payload, - version="v1", - source_fingerprint=f"{city}:market", - ) - return payload.get("market_scan_payload") or {} - - -def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict: - data = _analyze_summary(city, force_refresh=force_refresh) - payload = _build_city_summary_payload(data) - _CACHE_DB.set_city_cache( - "summary", - city, - payload, - version="v1", - source_fingerprint=f"{city}:summary", - ) - return payload - - -def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict: - payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="panel") - _CACHE_DB.set_city_cache( - "panel", - city, - payload, - version="v1", - source_fingerprint=f"{city}:panel", - ) - return payload - - -def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict: - payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="nearby") - _CACHE_DB.set_city_cache( - "nearby", - city, - payload, - version="v1", - source_fingerprint=f"{city}:nearby", - ) - return payload - - -def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict: - payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="market") - now_ts = time.time() - payload["market_analysis_cached_at"] = datetime.now().isoformat() - payload["market_analysis_cached_at_ts"] = now_ts - _attach_market_scan_payload(payload) - _CACHE_DB.set_city_cache( - "market", - city, - payload, - version="v1", - source_fingerprint=f"{city}:market", - ) - return payload - - -def _build_history_model_reference( - *, - forecasts: dict, - actual: object, - deb: object, -) -> dict: - """Expose the archived model snapshot as reference evidence, not truth.""" - actual_value = _sf(actual) - deb_value = _sf(deb) - entries = [] - for model_name, model_value in (forecasts or {}).items(): - if _is_excluded_model_name(str(model_name)): - continue - value = _sf(model_value) - if value is None: - continue - error = abs(value - actual_value) if actual_value is not None else None - entries.append( - { - "model": str(model_name), - "value": round(value, 1), - "error": round(error, 1) if error is not None else None, - "participates_in_deb": True, - } - ) - entries.sort( - key=lambda row: ( - row["error"] is None, - row["error"] if row["error"] is not None else 999, - row["model"], - ) - ) - deb_error = abs(deb_value - actual_value) if deb_value is not None and actual_value is not None else None - return { - "available": bool(entries), - "truth_layer": "settlement_actual", - "reference_layer": "archived_model_snapshot", - "deb": { - "value": round(deb_value, 1) if deb_value is not None else None, - "error": round(deb_error, 1) if deb_error is not None else None, - }, - "models": entries, - "model_count": len(entries), - } - - -def _build_city_history_payload(city: str, include_records: bool = False) -> dict: - source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower() - truth_rows = _truth_record_repo.load_city(city) - feature_rows = _training_feature_repo.load_city(city) - - if not truth_rows and not feature_rows: - project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - history_file = os.path.join(project_root, "data", "daily_records.json") - data = load_history(history_file) - city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {} - else: - all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys())) - city_data = {} - for day in all_dates: - record: dict[str, object] = {} - truth = truth_rows.get(day) or {} - features = feature_rows.get(day) or {} - if truth.get("actual_high") is not None: - record["actual_high"] = truth.get("actual_high") - record["settlement_source"] = truth.get("settlement_source") - record["settlement_station_code"] = truth.get("settlement_station_code") - record["settlement_station_label"] = truth.get("settlement_station_label") - record["truth_version"] = truth.get("truth_version") - record["updated_by"] = truth.get("updated_by") - record["truth_updated_at"] = truth.get("truth_updated_at") - if isinstance(features, dict): - if features.get("deb_prediction") is not None: - record["deb_prediction"] = features.get("deb_prediction") - if features.get("mu") is not None: - record["mu"] = features.get("mu") - if isinstance(features.get("forecasts"), dict): - record["forecasts"] = features.get("forecasts") - city_data[day] = record - - if not city_data: - return { - "history": [], - "mode": "full" if include_records else "preview", - "has_more": False, - "full_count": 0, - "preview_count": 0, - "settlement_source": source, - "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()), - } - - all_days = sorted(city_data.keys()) - selected_days = all_days if include_records else all_days[-HISTORY_PREVIEW_DAY_LIMIT:] - out = [] - for day in selected_days: - rec = city_data.get(day, {}) - if not isinstance(rec, dict): - rec = {} - - act = rec.get("actual_high") - deb = rec.get("deb_prediction") - mu = rec.get("mu") - snapshots = load_snapshot_rows_for_day(city, day) - peak_ref = _build_peak_minus_12h_reference( - actual_high=act, - snapshots=snapshots, - ) - forecasts_raw = rec.get("forecasts", {}) or {} - forecasts = {} - if isinstance(forecasts_raw, dict): - for model_name, model_value in forecasts_raw.items(): - if _is_excluded_model_name(str(model_name)): - continue - fv = _sf(model_value) - forecasts[str(model_name)] = fv if fv is not None else None - forecasts = _merge_missing_history_forecasts_from_snapshots( - forecasts, - snapshots, - ) - model_reference = _build_history_model_reference( - forecasts=forecasts, - actual=act, - deb=deb, - ) - mgm = forecasts.get("MGM") - out.append( - { - "date": day, - "actual": float(act) if act is not None else None, - "deb": float(deb) if deb is not None else None, - "mu": float(mu) if mu is not None else None, - "mgm": float(mgm) if mgm is not None else None, - "forecasts": forecasts, - "model_reference": model_reference, - "settlement_source": rec.get("settlement_source"), - "settlement_station_code": rec.get("settlement_station_code"), - "settlement_station_label": rec.get("settlement_station_label"), - "truth_version": rec.get("truth_version"), - "updated_by": rec.get("updated_by"), - "truth_updated_at": rec.get("truth_updated_at"), - "actual_peak_time": peak_ref.get("actual_peak_time"), - "deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"), - "deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"), - "deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"), - } - ) - - return { - "history": out, - "mode": "full" if include_records else "preview", - "has_more": len(all_days) > len(selected_days), - "full_count": len(all_days), - "preview_count": len(out), - "settlement_source": source, - "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()), - } - - -def _refresh_city_history_preview_cache(city: str) -> dict: - payload = _build_city_history_payload(city, include_records=False) - _CACHE_DB.set_city_cache( - "history_preview", - city, - payload, - version="v1", - source_fingerprint=f"{city}:history_preview", - ) - return payload - - -def _schedule_cache_refresh( - background_tasks: BackgroundTasks, - *, - kind: str, - city: str, - force_refresh: bool = False, -) -> bool: - normalized_kind = str(kind or "").strip().lower() - normalized_city = str(city or "").strip().lower() - if normalized_kind not in {"summary", "panel", "nearby", "market", "history_preview"} or not normalized_city: - return False - cache_key = f"city:{normalized_kind}:{normalized_city}" - owner = _CACHE_DB.acquire_cache_refresh_lock( - cache_key, - ttl_sec=CACHE_REFRESH_LOCK_TTL_SEC, - ) - if not owner: - return False - - def _runner() -> None: - try: - if normalized_kind == "summary": - _refresh_city_summary_cache(normalized_city, force_refresh=force_refresh) - elif normalized_kind == "panel": - _refresh_city_panel_cache(normalized_city, force_refresh=force_refresh) - elif normalized_kind == "nearby": - _refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh) - elif normalized_kind == "history_preview": - _refresh_city_history_preview_cache(normalized_city) - else: - _refresh_city_market_cache(normalized_city, force_refresh=force_refresh) - except Exception as exc: - logger.warning( - "cache refresh failed kind={} city={} force_refresh={}: {}", - normalized_kind, - normalized_city, - force_refresh, - exc, - ) - finally: - _CACHE_DB.release_cache_refresh_lock(cache_key, owner) - - background_tasks.add_task(_runner) - return True - - -def _parse_snapshot_dt(value: object) -> Optional[datetime]: - raw = str(value or "").strip() - if not raw: - return None - try: - return datetime.fromisoformat(raw.replace("Z", "+00:00")) - except Exception: - return None - - -def _build_peak_minus_12h_reference( - *, - actual_high: object, - snapshots: list[dict], -) -> dict: - actual = _sf(actual_high) - if actual is None or not snapshots: - return {} - - tolerance = 0.11 - normalized = [] - for row in snapshots: - if not isinstance(row, dict): - continue - dt = _parse_snapshot_dt(row.get("timestamp")) - if dt is None: - continue - normalized.append( - { - "dt": dt, - "max_so_far": _sf(row.get("max_so_far")), - "deb_prediction": _sf(row.get("deb_prediction")), - } - ) - if not normalized: - return {} - - peak_row = next( - ( - row - for row in normalized - if row["max_so_far"] is not None and row["max_so_far"] >= actual - tolerance - ), - None, - ) - if peak_row is None: - return {} - - peak_dt = peak_row["dt"] - anchor_dt = peak_dt - timedelta(hours=12) - anchor_row = None - for row in normalized: - if row["dt"] <= anchor_dt and row["deb_prediction"] is not None: - anchor_row = row - elif row["dt"] > anchor_dt: - break - - peak_time = peak_dt.strftime("%H:%M") - result = { - "actual_peak_time": peak_time, - } - if anchor_row and anchor_row["deb_prediction"] is not None: - deb_value = float(anchor_row["deb_prediction"]) - result.update( - { - "deb_at_peak_minus_12h": deb_value, - "deb_at_peak_minus_12h_time": anchor_row["dt"].strftime("%H:%M"), - "deb_at_peak_minus_12h_error": round(deb_value - actual, 1), - } - ) - return result - - -def _merge_missing_history_forecasts_from_snapshots( - forecasts: dict, - snapshots: list[dict], -) -> dict: - merged = dict(forecasts or {}) - if not snapshots: - return merged - - fallback_values: dict[str, Optional[float]] = {} - for row in snapshots: - if not isinstance(row, dict): - continue - multi_model = row.get("multi_model") or {} - if not isinstance(multi_model, dict): - continue - for model_name, model_value in multi_model.items(): - model_key = str(model_name or "").strip() - if not model_key or _is_excluded_model_name(model_key): - continue - parsed = _sf(model_value) - if parsed is not None: - fallback_values[model_key] = parsed - - for model_name, model_value in fallback_values.items(): - existing = _sf(merged.get(model_name)) - if existing is None: - merged[model_name] = model_value - return merged - - -def _normalize_city_or_404(name: str) -> str: - city = name.lower().strip().replace("-", " ") - city = ALIASES.get(city, city) - if city not in CITIES: - raise HTTPException(404, detail=f"Unknown city: {city}") - return city - - -def _normalize_city_list(raw: Optional[str]) -> list[str]: - if not raw: - return list(DEFAULT_PREWARM_CITIES) - out: list[str] = [] - for part in str(raw).split(","): - city = str(part or "").strip().lower().replace("-", " ") - if not city: - continue - city = ALIASES.get(city, city) - if city in CITIES and city not in out: - out.append(city) - return out - - -def _select_priority_city_batches(client_timezone: Optional[str]) -> dict[str, object]: - tz = str(client_timezone or "").strip() - normalized = tz.lower() - if normalized.startswith("america/"): - primary = list(US_CORE_CITIES) - secondary = [] - region = "america" - elif normalized.startswith("europe/"): - primary = list(EUROPE_CORE_CITIES) - secondary = list(ASIA_CORE_CITIES) - region = "europe" - elif normalized.startswith("asia/") or normalized.startswith("australia/") or normalized.startswith("pacific/"): - primary = list(ASIA_CORE_CITIES) - secondary = list(EUROPE_CORE_CITIES) - region = "asia" - else: - primary = list(ASIA_CORE_CITIES) - secondary = list(EUROPE_CORE_CITIES) - region = "default" - return { - "region": region, - "timezone": tz or None, - "primary": primary, - "secondary": secondary, - } - - -def _history_file_path() -> str: - project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - return os.path.join(project_root, "data", "daily_records.json") - - -def _build_recent_deb_performance_index( - history_data: Optional[dict] = None, - *, - lookback: int = _DEB_RECENT_LOOKBACK, - min_samples: int = _DEB_RECENT_MIN_SAMPLES, -) -> dict[str, dict[str, object]]: - index: dict[str, dict[str, object]] = {} - today = datetime.utcnow().strftime("%Y-%m-%d") - settled_by_city: dict[str, list[tuple[str, float, float]]] = {} - - if isinstance(history_data, dict): - for city_name, rows in history_data.items(): - if not isinstance(rows, dict): - continue - settled: list[tuple[str, float, float]] = [] - for date_key in sorted(rows.keys(), reverse=True): - if date_key >= today: - continue - record = rows.get(date_key) or {} - if not isinstance(record, dict): - continue - actual = _sf(record.get("actual_high")) - deb_prediction = _sf(record.get("deb_prediction")) - if actual is None or deb_prediction is None: - continue - settled.append((date_key, actual, deb_prediction)) - if len(settled) >= max(lookback, 1): - break - settled_by_city[str(city_name).strip().lower()] = settled - elif get_state_storage_mode() == STATE_STORAGE_SQLITE: - recent_rows = _daily_record_repo.load_recent_settled_rows( - before_date=today, - per_city_limit=max(lookback, 1), - ) - for city_name, rows in recent_rows.items(): - settled: list[tuple[str, float, float]] = [] - for row in rows: - actual = _sf(row.get("actual_high")) - deb_prediction = _sf(row.get("deb_prediction")) - date_key = str(row.get("target_date") or "").strip() - if not date_key or actual is None or deb_prediction is None: - continue - settled.append((date_key, actual, deb_prediction)) - settled_by_city[str(city_name).strip().lower()] = settled - else: - data = load_history(_history_file_path()) - if not isinstance(data, dict): - return index - for city_name, rows in data.items(): - if not isinstance(rows, dict): - continue - settled: list[tuple[str, float, float]] = [] - for date_key in sorted(rows.keys(), reverse=True): - if date_key >= today: - continue - record = rows.get(date_key) or {} - if not isinstance(record, dict): - continue - actual = _sf(record.get("actual_high")) - deb_prediction = _sf(record.get("deb_prediction")) - if actual is None or deb_prediction is None: - continue - settled.append((date_key, actual, deb_prediction)) - if len(settled) >= max(lookback, 1): - break - settled_by_city[str(city_name).strip().lower()] = settled - - for city_name, settled in settled_by_city.items(): - if not settled: - continue - - hit_count = 0 - abs_errors: list[float] = [] - for _, actual, deb_prediction in settled: - abs_errors.append(abs(deb_prediction - actual)) - if apply_city_settlement(city_name, actual) == apply_city_settlement(city_name, deb_prediction): - hit_count += 1 - - sample_count = len(settled) - hit_rate = (hit_count / sample_count) if sample_count > 0 else None - if sample_count < min_samples: - tier = "other" - elif hit_rate is not None and hit_rate >= 0.67: - tier = "high" - elif hit_rate is not None and hit_rate >= 0.34: - tier = "medium" - else: - tier = "low" - - index[str(city_name).strip().lower()] = { - "tier": tier, - "sample_count": sample_count, - "hit_rate": round(hit_rate, 4) if hit_rate is not None else None, - "mae": round(sum(abs_errors) / sample_count, 3) if sample_count > 0 else None, - "last_date": settled[0][0] if settled else None, - } - return index - - -@router.get("/healthz") -async def healthz(): - payload = build_health_payload() - if payload.get("status") != "ok": - raise HTTPException(status_code=503, detail=payload) - return payload - - -@router.get("/api/system/status") -async def system_status(): - return await run_in_threadpool(build_system_status_payload) - - -@router.post("/api/system/prewarm") -async def system_prewarm( - request: Request, - cities: Optional[str] = None, - force_refresh: bool = False, - include_detail: bool = False, - include_market: bool = False, -): - _assert_entitlement(request) - selected = _normalize_city_list(cities) - if not selected: - raise HTTPException(status_code=400, detail="No valid cities to prewarm") - - started = time.perf_counter() - warmed: list[dict[str, object]] = [] - failed: list[dict[str, object]] = [] - summary_ok = 0 - detail_ok = 0 - market_ok = 0 - - for city in selected: - city_started = time.perf_counter() - try: - _refresh_city_summary_cache(city, force_refresh=force_refresh) - entry: dict[str, object] = { - "city": city, - "summary": True, - "duration_ms": round((time.perf_counter() - city_started) * 1000.0, 1), - } - summary_ok += 1 - if include_detail: - _refresh_city_panel_cache(city, force_refresh=force_refresh) - entry["detail"] = True - detail_ok += 1 - if include_market: - entry["market"] = False - warmed.append(entry) - except Exception as exc: - failed.append( - { - "city": city, - "error": str(exc), - "duration_ms": round((time.perf_counter() - city_started) * 1000.0, 1), - } - ) - - total_ms = round((time.perf_counter() - started) * 1000.0, 1) - logger.info( - "system prewarm finished count={} failed={} force_refresh={} include_detail={} include_market={} duration_ms={}", - len(warmed), - len(failed), - force_refresh, - include_detail, - include_market, - total_ms, - ) - return { - "ok": len(failed) == 0, - "cities": selected, - "warmed": warmed, - "failed": failed, - "summary_ok": summary_ok, - "panel_ok": detail_ok, - "detail_ok": detail_ok, - "market_ok": market_ok, - "failed_count": len(failed), - "duration_ms": total_ms, - } - - -@router.get("/metrics", response_class=PlainTextResponse) -async def metrics(): - return PlainTextResponse( - export_prometheus_metrics(), - media_type="text/plain; version=0.0.4; charset=utf-8", - ) - - -@router.get("/api/cities") -async def list_cities(request: Request): - def _build_payload(): - out = [] - deb_recent_index = _build_recent_deb_performance_index() - for name, info in CITIES.items(): - risk = CITY_RISK_PROFILES.get(name, {}) - city_meta = CITY_REGISTRY.get(name, {}) or {} - deb_recent = deb_recent_index.get(name, {}) - settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar" - provider = get_country_network_provider(name) - out.append( - { - "name": name, - "display_name": str(city_meta.get("display_name") or city_meta.get("name") or name.title()), - "lat": info["lat"], - "lon": info["lon"], - "utc_offset_seconds": get_city_utc_offset_seconds(name), - "risk_level": risk.get("risk_level", "low"), - "risk_emoji": risk.get("risk_emoji", "🟢"), - "airport": risk.get("airport_name", ""), - "icao": risk.get("icao", ""), - "temp_unit": "fahrenheit" if info["f"] else "celsius", - "is_major": city_meta.get("is_major", True), - "settlement_source": settlement_source, - "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get( - settlement_source, - settlement_source.upper(), - ), - "settlement_station_code": city_meta.get("settlement_station_code") or city_meta.get("icao"), - "settlement_station_label": city_meta.get("settlement_station_label") or city_meta.get("airport_name"), - "network_provider": provider.provider_code, - "network_provider_label": provider.provider_label, - "deb_recent_tier": deb_recent.get("tier", "other"), - "deb_recent_hit_rate": deb_recent.get("hit_rate"), - "deb_recent_sample_count": deb_recent.get("sample_count", 0), - "deb_recent_mae": deb_recent.get("mae"), - "deb_recent_last_date": deb_recent.get("last_date"), - } - ) - return {"cities": out} - - try: - return await run_in_threadpool(_build_payload) - except Exception as exc: - logger.error(f"Error in list_cities: {exc}") - raise HTTPException(status_code=500, detail=str(exc)) from exc - - -@router.get("/api/city/{name}") -async def city_detail( - request: Request, - background_tasks: BackgroundTasks, - name: str, - force_refresh: bool = False, - depth: str = "panel", -): - _assert_entitlement(request) - city = _normalize_city_or_404(name) - normalized_depth = str(depth or "panel").strip().lower() - if normalized_depth == "full": - detail_mode = "full" - elif normalized_depth == "market": - detail_mode = "market" - elif normalized_depth == "nearby": - detail_mode = "nearby" - else: - detail_mode = "panel" - if detail_mode == "panel": - if force_refresh: - return await run_in_threadpool(_refresh_city_panel_cache, city, True) - cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "panel", city) - if cached_entry: - if not _city_cache_is_fresh(cached_entry, CITY_PANEL_CACHE_TTL_SEC): - return await run_in_threadpool(_refresh_city_panel_cache, city, False) - return cached_entry.get("payload") or {} - return await run_in_threadpool(_refresh_city_panel_cache, city, False) - if detail_mode == "nearby": - if force_refresh: - return await run_in_threadpool(_refresh_city_nearby_cache, city, True) - cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "nearby", city) - if cached_entry: - if not _city_cache_is_fresh(cached_entry, CITY_NEARBY_CACHE_TTL_SEC): - return await run_in_threadpool(_refresh_city_nearby_cache, city, False) - return cached_entry.get("payload") or {} - return await run_in_threadpool(_refresh_city_nearby_cache, city, False) - if detail_mode == "market": - if force_refresh: - return await run_in_threadpool(_refresh_city_market_cache, city, True) - cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city) - if cached_entry: - if not _market_analysis_cache_is_fresh(cached_entry): - return await run_in_threadpool(_refresh_city_market_cache, city, False) - return cached_entry.get("payload") or {} - return await run_in_threadpool(_refresh_city_market_cache, city, False) - return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode) - - -@router.get("/api/history/{name}") -async def city_history( - request: Request, - background_tasks: BackgroundTasks, - name: str, - include_records: bool = False, -): - _assert_entitlement(request) - city = _normalize_city_or_404(name) - if include_records: - return await run_in_threadpool(_build_city_history_payload, city, True) - cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "history_preview", city) - if cached_entry: - if not _city_cache_is_fresh(cached_entry, CITY_HISTORY_PREVIEW_CACHE_TTL_SEC): - return await run_in_threadpool(_refresh_city_history_preview_cache, city) - return cached_entry.get("payload") or {} - return await run_in_threadpool(_refresh_city_history_preview_cache, city) - - -@router.get("/api/system/cache-status") -async def system_cache_status(request: Request, cities: Optional[str] = None): - _assert_entitlement(request) - selected = _normalize_city_list(cities) - if not selected: - selected = list(DEFAULT_PREWARM_CITIES) - kinds = { - "summary": CITY_SUMMARY_CACHE_TTL_SEC, - "panel": CITY_PANEL_CACHE_TTL_SEC, - "nearby": CITY_NEARBY_CACHE_TTL_SEC, - "market": CITY_MARKET_CACHE_TTL_SEC, - "history_preview": CITY_HISTORY_PREVIEW_CACHE_TTL_SEC, - } - items = [] - for city in selected: - row = {"city": city} - for kind, ttl_sec in kinds.items(): - entry = _CACHE_DB.get_city_cache(kind, city) - row[kind] = { - "exists": bool(entry), - "fresh": _city_cache_is_fresh(entry, ttl_sec), - "updated_at": entry.get("updated_at") if entry else None, - "age_sec": round(max(0.0, time.time() - float(entry.get("updated_at_ts") or 0.0)), 1) - if entry - else None, - "ttl_sec": ttl_sec, - } - items.append(row) - return {"cities": items} - - -@router.post("/api/system/priority-warm") -async def system_priority_warm( - request: Request, - background_tasks: BackgroundTasks, - timezone: Optional[str] = None, -): - _assert_entitlement(request) - batches = _select_priority_city_batches(timezone) - primary = list(batches.get("primary") or []) - secondary = list(batches.get("secondary") or []) - - def _runner() -> None: - for city in primary: - try: - _refresh_city_summary_cache(city, force_refresh=False) - _refresh_city_panel_cache(city, force_refresh=False) - _refresh_city_nearby_cache(city, force_refresh=False) - _refresh_city_market_cache(city, force_refresh=False) - except Exception as exc: - logger.warning("priority warm primary failed city={} timezone={}: {}", city, timezone, exc) - for city in secondary: - try: - _refresh_city_summary_cache(city, force_refresh=False) - _refresh_city_panel_cache(city, force_refresh=False) - except Exception as exc: - logger.warning("priority warm secondary failed city={} timezone={}: {}", city, timezone, exc) - - background_tasks.add_task(_runner) - return { - "ok": True, - "region": batches.get("region"), - "timezone": batches.get("timezone"), - "primary": primary, - "secondary": secondary, - } - - -@router.get("/api/auth/me") -async def auth_me(request: Request): - _assert_entitlement(request) - _bind_optional_supabase_identity(request) - - user_id = getattr(request.state, "auth_user_id", None) - subscription_required = bool( - SUPABASE_ENTITLEMENT.enabled - and _SUPABASE_AUTH_REQUIRED - and SUPABASE_ENTITLEMENT.require_subscription - ) - subscription_active = None - subscription_plan_code = None - subscription_starts_at = None - subscription_expires_at = None - subscription_total_expires_at = None - subscription_queued_days = 0 - subscription_queued_count = 0 - - if SUPABASE_ENTITLEMENT.enabled and user_id: - try: - latest_subscription = SUPABASE_ENTITLEMENT.ensure_signup_trial( - user_id, - created_at=getattr(request.state, "auth_created_at", None), - ) - if not latest_subscription: - latest_subscription = SUPABASE_ENTITLEMENT.get_latest_active_subscription( - user_id, - respect_requirement=False, - ) - - latest_known_subscription = latest_subscription - if not latest_known_subscription: - latest_known_subscription = ( - SUPABASE_ENTITLEMENT.get_latest_subscription_any_status(user_id) - ) - subscription_window = SUPABASE_ENTITLEMENT.get_subscription_window( - user_id, - respect_requirement=False, - ) - subscription_active = bool(latest_subscription) - if isinstance(latest_subscription, dict): - subscription_plan_code = latest_subscription.get("plan_code") - subscription_starts_at = latest_subscription.get("starts_at") - subscription_expires_at = latest_subscription.get("expires_at") - elif isinstance(latest_known_subscription, dict): - subscription_plan_code = latest_known_subscription.get("plan_code") - subscription_starts_at = latest_known_subscription.get("starts_at") - subscription_expires_at = latest_known_subscription.get("expires_at") - if isinstance(subscription_window, dict): - subscription_total_expires_at = subscription_window.get("total_expires_at") - subscription_queued_days = int(subscription_window.get("queued_days") or 0) - subscription_queued_count = int(subscription_window.get("queued_count") or 0) - except Exception: - subscription_active = None - subscription_plan_code = None - subscription_starts_at = None - subscription_expires_at = None - subscription_total_expires_at = None - subscription_queued_days = 0 - subscription_queued_count = 0 - - points = _resolve_auth_points(request) - weekly_profile = _resolve_weekly_profile(request) - - return { - "authenticated": bool(user_id), - "user_id": user_id, - "email": getattr(request.state, "auth_email", None), - "points": points, - "weekly_points": weekly_profile["weekly_points"], - "weekly_rank": weekly_profile["weekly_rank"], - "entitlement_mode": ( - "supabase_required" - if SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED - else "supabase_optional" - if SUPABASE_ENTITLEMENT.enabled - else "legacy_token" - if _ENTITLEMENT_GUARD_ENABLED - else "disabled" - ), - "auth_required": bool(SUPABASE_ENTITLEMENT.enabled and _SUPABASE_AUTH_REQUIRED), - "subscription_required": subscription_required, - "subscription_active": subscription_active, - "subscription_plan_code": subscription_plan_code, - "subscription_starts_at": subscription_starts_at, - "subscription_expires_at": subscription_expires_at, - "subscription_total_expires_at": subscription_total_expires_at, - "subscription_queued_days": subscription_queued_days, - "subscription_queued_count": subscription_queued_count, - } - - -@router.post("/api/analytics/events") -async def analytics_track(request: Request, body: AnalyticsEventRequest): - _bind_optional_supabase_identity(request) - event_type = str(body.event_type or "").strip().lower() - if event_type not in TRACKABLE_ANALYTICS_EVENTS: - raise HTTPException(status_code=400, detail="unsupported_event_type") - - payload = body.payload if isinstance(body.payload, dict) else {} - normalized_payload = { - key: value - for key, value in payload.items() - if isinstance(key, str) and len(key) <= 64 - } - from src.database.db_manager import DBManager - - db = DBManager() - db.append_app_analytics_event( - event_type, - normalized_payload, - user_id=getattr(request.state, "auth_user_id", None), - client_id=body.client_id, - session_id=body.session_id, - ) - return {"ok": True} - - -@router.get("/api/ops/users") -async def ops_search_users(request: Request, q: str = "", limit: int = 20): - _assert_entitlement(request) - _require_ops_admin(request) - from src.database.db_manager import DBManager - - db = DBManager() - users = db.search_users(q, limit=limit) - return {"users": users} - - -@router.get("/api/ops/leaderboard/weekly") -async def ops_weekly_leaderboard(request: Request, limit: int = 20): - _assert_entitlement(request) - _require_ops_admin(request) - from src.database.db_manager import DBManager - - db = DBManager() - return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} - - -@router.get("/api/ops/memberships") -async def ops_memberships(request: Request, limit: int = 200): - _assert_entitlement(request) - _require_ops_admin(request) - from src.database.db_manager import DBManager - - db = DBManager() - if getattr(PAYMENT_CHECKOUT, "enabled", False): - try: - PAYMENT_CHECKOUT.reconcile_recent_intents(limit=min(max(int(limit or 200), 20), 200)) - except Exception: - pass - subscriptions = SUPABASE_ENTITLEMENT.list_active_subscriptions(limit=limit) - subscription_user_ids = [str(item.get("user_id") or "") for item in subscriptions] - user_map = db.get_users_by_supabase_user_ids(subscription_user_ids) - unresolved_user_ids = [ - user_id - for user_id in subscription_user_ids - if str(user_id or "").strip().lower() - and not str( - (user_map.get(str(user_id).strip().lower(), {}) or {}).get("supabase_email") or "" - ).strip() - ] - auth_user_map = SUPABASE_ENTITLEMENT.get_auth_users(unresolved_user_ids) - deduped: dict[str, dict] = {} - for item in subscriptions: - user_id = str(item.get("user_id") or "").strip().lower() - local_user = user_map.get(user_id, {}) - auth_user = auth_user_map.get(user_id, {}) - subscription_window = SUPABASE_ENTITLEMENT.get_subscription_window( - user_id, - respect_requirement=False, - bypass_cache=True, - ) - current_expires_at = item.get("expires_at") - total_expires_at = ( - subscription_window.get("total_expires_at") - if isinstance(subscription_window, dict) - else None - ) - queued_days = ( - int(subscription_window.get("queued_days") or 0) - if isinstance(subscription_window, dict) - else 0 - ) - queued_count = ( - int(subscription_window.get("queued_count") or 0) - if isinstance(subscription_window, dict) - else 0 - ) - row = { - "user_id": user_id, - "email": str(auth_user.get("email") or local_user.get("supabase_email") or ""), - "telegram_id": local_user.get("telegram_id"), - "username": local_user.get("username"), - "registered_at": local_user.get("created_at") or auth_user.get("created_at"), - "plan_code": item.get("plan_code"), - "starts_at": item.get("starts_at"), - "current_expires_at": current_expires_at, - "total_expires_at": total_expires_at or current_expires_at, - "expires_at": total_expires_at or current_expires_at, - "queued_days": queued_days, - "queued_count": queued_count, - } - existing = deduped.get(user_id) - existing_expires = str(existing.get("expires_at") or "") if existing else "" - current_expires = str(row.get("expires_at") or "") - if existing is None or current_expires > existing_expires: - deduped[user_id] = row - rows = sorted( - deduped.values(), - key=lambda item: str(item.get("expires_at") or ""), - ) - return {"memberships": rows} - - -@router.get("/api/ops/payments/incidents") -async def ops_payment_incidents( - request: Request, - limit: int = 50, - reason: str = "", - include_resolved: bool = False, -): - _assert_entitlement(request) - _require_ops_admin(request) - from src.database.db_manager import DBManager - - db = DBManager() - incidents = db.list_payment_audit_events( - limit=max(1, min(int(limit or 50), 200)), - event_type="payment_intent_failed", - ) - normalized_reason = str(reason or "").strip().lower() - filtered = [] - for item in incidents: - payload = item.get("payload") if isinstance(item, dict) else {} - payload = payload if isinstance(payload, dict) else {} - item_reason = str(payload.get("reason") or "").strip().lower() - resolved_at = str(payload.get("resolved_at") or "").strip() - if normalized_reason and item_reason != normalized_reason: - continue - if not include_resolved and resolved_at: - continue - filtered.append(item) - return {"incidents": filtered} - - -@router.post("/api/ops/payments/incidents/{event_id}/resolve") -async def ops_resolve_payment_incident(request: Request, event_id: int): - _assert_entitlement(request) - admin = _require_ops_admin(request) - from src.database.db_manager import DBManager - - db = DBManager() - resolved = db.mark_payment_audit_event_resolved(event_id, str(admin.get("email") or "")) - if not resolved: - raise HTTPException(status_code=404, detail="payment_incident_not_found") - return {"ok": True, "incident": resolved} - - -@router.post("/api/ops/users/grant-points") -async def ops_grant_points(request: Request, body: GrantPointsRequest): - _assert_entitlement(request) - admin = _require_ops_admin(request) - from src.database.db_manager import DBManager - - db = DBManager() - result = db.grant_points_by_supabase_email(body.email, body.points) - result["operator_email"] = admin.get("email") - if not result.get("ok"): - reason = str(result.get("reason") or "grant_points_failed") - status_code = 404 if reason == "user_not_found" else 400 - raise HTTPException(status_code=status_code, detail=result) - return result - - -@router.get("/api/ops/analytics/funnel") -async def ops_analytics_funnel(request: Request, days: int = 30): - _assert_entitlement(request) - _require_ops_admin(request) - from src.database.db_manager import DBManager - - db = DBManager() - return db.get_app_analytics_funnel_summary(days=days) - - -@router.get("/api/ops/truth-history") -async def ops_truth_history( - request: Request, - city: str = "", - date_from: str = "", - date_to: str = "", - limit: int = 200, -): - _assert_entitlement(request) - _require_ops_admin(request) - - truth_history = TruthRecordRepository().load_all() - normalized_city = str(city or "").strip().lower() - normalized_from = str(date_from or "").strip() - normalized_to = str(date_to or "").strip() - max_limit = max(1, min(int(limit or 200), 1000)) - - rows = [] - for row_city, by_date in truth_history.items(): - if normalized_city and row_city != normalized_city: - continue - if not isinstance(by_date, dict): - continue - for target_date, payload in by_date.items(): - if normalized_from and str(target_date) < normalized_from: - continue - if normalized_to and str(target_date) > normalized_to: - continue - if not isinstance(payload, dict): - continue - rows.append( - { - "city": row_city, - "display_name": str((CITY_REGISTRY.get(row_city) or {}).get("name") or row_city), - "target_date": str(target_date), - "actual_high": payload.get("actual_high"), - "settlement_source": payload.get("settlement_source"), - "settlement_station_code": payload.get("settlement_station_code"), - "settlement_station_label": payload.get("settlement_station_label"), - "truth_version": payload.get("truth_version"), - "updated_by": payload.get("updated_by"), - "truth_updated_at": payload.get("truth_updated_at"), - "is_final": payload.get("is_final"), - } - ) - - rows.sort(key=lambda item: (str(item["target_date"]), str(item["city"])), reverse=True) - filtered_count = len(rows) - rows = rows[:max_limit] - available_cities = [ - { - "city": city_id, - "name": str(info.get("name") or city_id), - } - for city_id, info in sorted(CITY_REGISTRY.items(), key=lambda item: str(item[1].get("name") or item[0])) - ] - return { - "items": rows, - "available_cities": available_cities, - "filters": { - "city": normalized_city or None, - "date_from": normalized_from or None, - "date_to": normalized_to or None, - "limit": max_limit, - }, - "filtered_count": filtered_count, - } - - -@router.get("/api/payments/config") -async def payment_config(request: Request): - _assert_entitlement(request) - try: - return PAYMENT_CHECKOUT.get_config_payload() - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.get("/api/payments/runtime") -async def payment_runtime(request: Request): - _assert_entitlement(request) - try: - from src.database.db_manager import DBManager - - db = DBManager() - return { - "checkout": PAYMENT_CHECKOUT.get_config_payload(), - "rpc": PAYMENT_CHECKOUT.get_rpc_runtime_status(), - "event_loop_state": db.get_payment_runtime_state("payment_event_loop") or {}, - "recent_audit_events": db.list_payment_audit_events(limit=20), - } - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.get("/api/payments/wallets") -async def payment_wallets(request: Request): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - wallets = PAYMENT_CHECKOUT.list_wallets(identity["user_id"]) - return { - "wallets": [wallet.__dict__ for wallet in wallets], - "chain_id": PAYMENT_CHECKOUT.chain_id, - } - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.delete("/api/payments/wallets") -async def payment_wallet_unbind(request: Request, body: WalletUnbindRequest): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - return PAYMENT_CHECKOUT.unbind_wallet( - user_id=identity["user_id"], - address=body.address, - ) - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.post("/api/payments/wallets/challenge") -async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - return PAYMENT_CHECKOUT.create_wallet_challenge( - user_id=identity["user_id"], - address=body.address, - ) - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.post("/api/payments/wallets/verify") -async def payment_wallet_verify(request: Request, body: WalletVerifyRequest): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - bound = PAYMENT_CHECKOUT.verify_wallet_binding( - user_id=identity["user_id"], - address=body.address, - nonce=body.nonce, - signature=body.signature, - ) - return {"wallet": bound.__dict__} - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.post("/api/payments/intents") -async def payment_create_intent(request: Request, body: CreatePaymentIntentRequest): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - return PAYMENT_CHECKOUT.create_intent( - user_id=identity["user_id"], - plan_code=body.plan_code, - payment_mode=body.payment_mode, - allowed_wallet=body.allowed_wallet, - token_address=body.token_address, - use_points=body.use_points, - points_to_consume=body.points_to_consume, - metadata=body.metadata, - ) - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.get("/api/payments/intents/{intent_id}") -async def payment_get_intent(request: Request, intent_id: str): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - intent = PAYMENT_CHECKOUT.get_intent( - user_id=identity["user_id"], - intent_id=intent_id, - ) - return {"intent": intent.__dict__} - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.post("/api/payments/intents/{intent_id}/submit") -async def payment_submit_tx( - request: Request, - intent_id: str, - body: SubmitPaymentTxRequest, -): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - return PAYMENT_CHECKOUT.submit_intent_tx( - user_id=identity["user_id"], - intent_id=intent_id, - tx_hash=body.tx_hash, - from_address=body.from_address, - ) - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.post("/api/payments/intents/{intent_id}/confirm") -async def payment_confirm_tx( - request: Request, - intent_id: str, - body: ConfirmPaymentTxRequest, -): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - return PAYMENT_CHECKOUT.confirm_intent_tx( - user_id=identity["user_id"], - intent_id=intent_id, - tx_hash=body.tx_hash, - ) - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.post("/api/payments/reconcile-latest") -async def payment_reconcile_latest(request: Request): - _assert_entitlement(request) - identity = _require_supabase_identity(request) - try: - return PAYMENT_CHECKOUT.reconcile_latest_intent(identity["user_id"]) - except PaymentCheckoutError as exc: - raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc - - -@router.get("/api/city/{name}/summary") -async def city_summary( - request: Request, - background_tasks: BackgroundTasks, - name: str, - force_refresh: bool = False, -): - city = _normalize_city_or_404(name) - if force_refresh: - return await run_in_threadpool(_refresh_city_summary_cache, city, True) - cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "summary", city) - if cached_entry: - if not _city_cache_is_fresh(cached_entry, CITY_SUMMARY_CACHE_TTL_SEC): - return await run_in_threadpool(_refresh_city_summary_cache, city, False) - return cached_entry.get("payload") or {} - return await run_in_threadpool(_refresh_city_summary_cache, city, False) - - -@router.get("/api/city/{name}/detail") -async def city_detail_aggregate( - request: Request, - name: str, - force_refresh: bool = False, - market_slug: Optional[str] = None, - target_date: Optional[str] = None, -): - _assert_entitlement(request) - city = _normalize_city_or_404(name) - data = await run_in_threadpool(_analyze, city, force_refresh, True) - return await run_in_threadpool( - _build_city_detail_payload, - data, - market_slug, - target_date, - ) - - -@router.get("/api/city/{name}/market-scan") -async def city_market_scan( - request: Request, - background_tasks: BackgroundTasks, - name: str, - force_refresh: bool = False, - market_slug: Optional[str] = None, - target_date: Optional[str] = None, - lite: bool = False, -): - _assert_entitlement(request) - city = _normalize_city_or_404(name) - if force_refresh: - data = await run_in_threadpool(_refresh_city_market_cache, city, True) - cached_scan = _get_cached_market_scan_payload( - data, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - if cached_scan is not None: - return cached_scan - else: - cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city) - if cached_entry: - data = cached_entry.get("payload") or {} - cached_scan = _get_cached_market_scan_payload( - data, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - if cached_scan is not None: - if not _market_analysis_cache_is_fresh(cached_entry): - _schedule_cache_refresh(background_tasks, kind="market", city=city) - return cached_scan - if _market_analysis_cache_is_fresh(cached_entry): - return await run_in_threadpool( - _refresh_market_scan_payload_from_cached_analysis, - city, - data, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - else: - _schedule_cache_refresh(background_tasks, kind="market", city=city) - else: - data = await run_in_threadpool(_refresh_city_market_cache, city, False) - cached_scan = _get_cached_market_scan_payload( - data, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - if cached_scan is not None: - return cached_scan - return await run_in_threadpool( - _build_city_market_scan_payload, - data, - market_slug, - target_date, - lite, - ) - - -@router.get("/api/scan/terminal") -async def scan_terminal( - request: Request, - scan_mode: str = "tradable", - min_price: float = 0.05, - max_price: float = 0.95, - min_edge_pct: float = 2.0, - min_liquidity: float = 500.0, - high_liquidity_only: bool = False, - market_type: str = "maxtemp", - time_range: str = "today", - limit: int = 25, - force_refresh: bool = False, -): - _assert_entitlement(request) - filters = { - "scan_mode": scan_mode, - "min_price": min_price, - "max_price": max_price, - "min_edge_pct": min_edge_pct, - "min_liquidity": min_liquidity, - "high_liquidity_only": high_liquidity_only, - "market_type": market_type, - "time_range": time_range, - "limit": limit, - } - return await run_in_threadpool( - build_scan_terminal_payload, - filters, - force_refresh=force_refresh, - ) - - -@router.post("/api/scan/terminal/ai") -async def scan_terminal_ai(request: Request): - _assert_entitlement(request) - try: - body = await request.json() - except Exception: - body = {} - if not isinstance(body, dict): - raise HTTPException(status_code=400, detail="Invalid JSON body") - filters = body.get("filters") if isinstance(body.get("filters"), dict) else {} - snapshot_id = str(body.get("snapshot_id") or "").strip() or None - return await run_in_threadpool( - build_scan_terminal_ai_payload, - filters, - snapshot_id=snapshot_id, - ) - - -@router.post("/api/scan/terminal/ai-city") -async def scan_terminal_ai_city(request: Request): - _assert_entitlement(request) - try: - body = await request.json() - except Exception: - body = {} - if not isinstance(body, dict): - raise HTTPException(status_code=400, detail="Invalid JSON body") - city = str(body.get("city") or "").strip() - if not city: - raise HTTPException(status_code=400, detail="city is required") - force_refresh = str(body.get("force_refresh") or "false").lower() in { - "1", - "true", - "yes", - "on", - } - locale = str(body.get("locale") or "zh-CN").strip() - return await run_in_threadpool( - build_scan_city_ai_forecast_payload, - city, - force_refresh=force_refresh, - locale=locale, - ) - - -@router.post("/api/scan/terminal/ai-city/stream") -async def scan_terminal_ai_city_stream(request: Request): - _assert_entitlement(request) - try: - body = await request.json() - except Exception: - body = {} - if not isinstance(body, dict): - raise HTTPException(status_code=400, detail="Invalid JSON body") - city = str(body.get("city") or "").strip() - if not city: - raise HTTPException(status_code=400, detail="city is required") - force_refresh = str(body.get("force_refresh") or "false").lower() in { - "1", - "true", - "yes", - "on", - } - locale = str(body.get("locale") or "zh-CN").strip() - return StreamingResponse( - stream_scan_city_ai_forecast_payload( - city, - force_refresh=force_refresh, - locale=locale, - ), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-store", - "X-Accel-Buffering": "no", - }, - ) +__all__ = list(_city_runtime.__all__) diff --git a/web/services/__init__.py b/web/services/__init__.py new file mode 100644 index 00000000..876565a3 --- /dev/null +++ b/web/services/__init__.py @@ -0,0 +1 @@ +"""Service/helper modules used by the FastAPI web layer.""" diff --git a/web/services/analytics_api.py b/web/services/analytics_api.py new file mode 100644 index 00000000..f5900bd9 --- /dev/null +++ b/web/services/analytics_api.py @@ -0,0 +1,35 @@ +"""Analytics API service functions.""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import HTTPException, Request + +from src.database.db_manager import DBManager +from web.core import AnalyticsEventRequest +import web.routes as legacy_routes + + +def track_analytics_event(request: Request, body: AnalyticsEventRequest) -> Dict[str, Any]: + legacy_routes._bind_optional_supabase_identity(request) + event_type = str(body.event_type or "").strip().lower() + if event_type not in legacy_routes.TRACKABLE_ANALYTICS_EVENTS: + raise HTTPException(status_code=400, detail="unsupported_event_type") + + payload = body.payload if isinstance(body.payload, dict) else {} + normalized_payload = { + key: value + for key, value in payload.items() + if isinstance(key, str) and len(key) <= 64 + } + + db = DBManager() + db.append_app_analytics_event( + event_type, + normalized_payload, + user_id=getattr(request.state, "auth_user_id", None), + client_id=body.client_id, + session_id=body.session_id, + ) + return {"ok": True} diff --git a/web/services/auth_api.py b/web/services/auth_api.py new file mode 100644 index 00000000..9f26460f --- /dev/null +++ b/web/services/auth_api.py @@ -0,0 +1,108 @@ +"""Authentication API service functions.""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import Request + +import web.routes as legacy_routes + + +def get_auth_me_payload(request: Request) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + legacy_routes._bind_optional_supabase_identity(request) + + user_id = getattr(request.state, "auth_user_id", None) + subscription_required = bool( + legacy_routes.SUPABASE_ENTITLEMENT.enabled + and legacy_routes.SUPABASE_ENTITLEMENT.require_subscription + ) + subscription_active = None + subscription_plan_code = None + subscription_starts_at = None + subscription_expires_at = None + subscription_total_expires_at = None + subscription_queued_days = 0 + subscription_queued_count = 0 + + if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id: + try: + latest_subscription = legacy_routes.SUPABASE_ENTITLEMENT.ensure_signup_trial( + user_id, + created_at=getattr(request.state, "auth_created_at", None), + ) + if not latest_subscription: + latest_subscription = ( + legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription( + user_id, + respect_requirement=False, + ) + ) + + latest_known_subscription = latest_subscription + if not latest_known_subscription: + latest_known_subscription = ( + legacy_routes.SUPABASE_ENTITLEMENT.get_latest_subscription_any_status( + user_id + ) + ) + subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( + user_id, + respect_requirement=False, + ) + subscription_active = bool(latest_subscription) + if isinstance(latest_subscription, dict): + subscription_plan_code = latest_subscription.get("plan_code") + subscription_starts_at = latest_subscription.get("starts_at") + subscription_expires_at = latest_subscription.get("expires_at") + elif isinstance(latest_known_subscription, dict): + subscription_plan_code = latest_known_subscription.get("plan_code") + subscription_starts_at = latest_known_subscription.get("starts_at") + subscription_expires_at = latest_known_subscription.get("expires_at") + if isinstance(subscription_window, dict): + subscription_total_expires_at = subscription_window.get("total_expires_at") + subscription_queued_days = int(subscription_window.get("queued_days") or 0) + subscription_queued_count = int(subscription_window.get("queued_count") or 0) + except Exception: + subscription_active = None + subscription_plan_code = None + subscription_starts_at = None + subscription_expires_at = None + subscription_total_expires_at = None + subscription_queued_days = 0 + subscription_queued_count = 0 + + points = legacy_routes._resolve_auth_points(request) + weekly_profile = legacy_routes._resolve_weekly_profile(request) + + return { + "authenticated": bool(user_id), + "user_id": user_id, + "email": getattr(request.state, "auth_email", None), + "points": points, + "weekly_points": weekly_profile["weekly_points"], + "weekly_rank": weekly_profile["weekly_rank"], + "entitlement_mode": ( + "supabase_required" + if legacy_routes.SUPABASE_ENTITLEMENT.enabled + and legacy_routes._SUPABASE_AUTH_REQUIRED + else "supabase_optional" + if legacy_routes.SUPABASE_ENTITLEMENT.enabled + else "legacy_token" + if legacy_routes._ENTITLEMENT_GUARD_ENABLED + else "disabled" + ), + "auth_required": bool( + legacy_routes.SUPABASE_ENTITLEMENT.enabled + and legacy_routes._SUPABASE_AUTH_REQUIRED + ), + "subscription_required": subscription_required, + "subscription_active": subscription_active, + "subscription_plan_code": subscription_plan_code, + "subscription_starts_at": subscription_starts_at, + "subscription_expires_at": subscription_expires_at, + "subscription_total_expires_at": subscription_total_expires_at, + "subscription_queued_days": subscription_queued_days, + "subscription_queued_count": subscription_queued_count, + } diff --git a/web/services/city_api.py b/web/services/city_api.py new file mode 100644 index 00000000..ff734a6b --- /dev/null +++ b/web/services/city_api.py @@ -0,0 +1,228 @@ +"""City API service functions used by the city router.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from fastapi import BackgroundTasks, HTTPException, Request +from fastapi.concurrency import run_in_threadpool +from loguru import logger + +import web.routes as legacy_routes + + +def _build_cities_payload() -> Dict[str, Any]: + out = [] + deb_recent_index = legacy_routes._build_recent_deb_performance_index() + for name, info in legacy_routes.CITIES.items(): + risk = legacy_routes.CITY_RISK_PROFILES.get(name, {}) + city_meta = legacy_routes.CITY_REGISTRY.get(name, {}) or {} + deb_recent = deb_recent_index.get(name, {}) + settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar" + provider = legacy_routes.get_country_network_provider(name) + out.append( + { + "name": name, + "display_name": str(city_meta.get("display_name") or city_meta.get("name") or name.title()), + "lat": info["lat"], + "lon": info["lon"], + "utc_offset_seconds": legacy_routes.get_city_utc_offset_seconds(name), + "risk_level": risk.get("risk_level", "low"), + "risk_emoji": risk.get("risk_emoji", "🟢"), + "airport": risk.get("airport_name", ""), + "icao": risk.get("icao", ""), + "temp_unit": "fahrenheit" if info["f"] else "celsius", + "is_major": city_meta.get("is_major", True), + "settlement_source": settlement_source, + "settlement_source_label": legacy_routes.SETTLEMENT_SOURCE_LABELS.get( + settlement_source, + settlement_source.upper(), + ), + "settlement_station_code": city_meta.get("settlement_station_code") or city_meta.get("icao"), + "settlement_station_label": city_meta.get("settlement_station_label") or city_meta.get("airport_name"), + "network_provider": provider.provider_code, + "network_provider_label": provider.provider_label, + "deb_recent_tier": deb_recent.get("tier", "other"), + "deb_recent_hit_rate": deb_recent.get("hit_rate"), + "deb_recent_sample_count": deb_recent.get("sample_count", 0), + "deb_recent_mae": deb_recent.get("mae"), + "deb_recent_last_date": deb_recent.get("last_date"), + } + ) + return {"cities": out} + + +async def list_cities_payload(_request: Request) -> Dict[str, Any]: + try: + return await run_in_threadpool(_build_cities_payload) + except Exception as exc: + logger.error(f"Error in list_cities: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +async def get_city_detail_payload( + request: Request, + name: str, + *, + force_refresh: bool = False, + depth: str = "panel", +) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + city = legacy_routes._normalize_city_or_404(name) + normalized_depth = str(depth or "panel").strip().lower() + if normalized_depth == "full": + detail_mode = "full" + elif normalized_depth == "market": + detail_mode = "market" + elif normalized_depth == "nearby": + detail_mode = "nearby" + else: + detail_mode = "panel" + if detail_mode == "panel": + if force_refresh: + return await run_in_threadpool(legacy_routes._refresh_city_panel_cache, city, True) + cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "panel", city) + if cached_entry: + if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_PANEL_CACHE_TTL_SEC): + return await run_in_threadpool(legacy_routes._refresh_city_panel_cache, city, False) + return cached_entry.get("payload") or {} + return await run_in_threadpool(legacy_routes._refresh_city_panel_cache, city, False) + if detail_mode == "nearby": + if force_refresh: + return await run_in_threadpool(legacy_routes._refresh_city_nearby_cache, city, True) + cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "nearby", city) + if cached_entry: + if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_NEARBY_CACHE_TTL_SEC): + return await run_in_threadpool(legacy_routes._refresh_city_nearby_cache, city, False) + return cached_entry.get("payload") or {} + return await run_in_threadpool(legacy_routes._refresh_city_nearby_cache, city, False) + if detail_mode == "market": + if force_refresh: + return await run_in_threadpool(legacy_routes._refresh_city_market_cache, city, True) + cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "market", city) + if cached_entry: + if not legacy_routes._market_analysis_cache_is_fresh(cached_entry): + return await run_in_threadpool(legacy_routes._refresh_city_market_cache, city, False) + return cached_entry.get("payload") or {} + return await run_in_threadpool(legacy_routes._refresh_city_market_cache, city, False) + return await run_in_threadpool(legacy_routes._analyze, city, force_refresh, False, detail_mode) + + +async def get_city_history_payload( + request: Request, + name: str, + *, + include_records: bool = False, +) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + city = legacy_routes._normalize_city_or_404(name) + if include_records: + return await run_in_threadpool(legacy_routes._build_city_history_payload, city, True) + cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "history_preview", city) + if cached_entry: + if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_HISTORY_PREVIEW_CACHE_TTL_SEC): + return await run_in_threadpool(legacy_routes._refresh_city_history_preview_cache, city) + return cached_entry.get("payload") or {} + return await run_in_threadpool(legacy_routes._refresh_city_history_preview_cache, city) + + +async def get_city_summary_payload( + _request: Request, + name: str, + *, + force_refresh: bool = False, +) -> Dict[str, Any]: + city = legacy_routes._normalize_city_or_404(name) + if force_refresh: + return await run_in_threadpool(legacy_routes._refresh_city_summary_cache, city, True) + cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "summary", city) + if cached_entry: + if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_SUMMARY_CACHE_TTL_SEC): + return await run_in_threadpool(legacy_routes._refresh_city_summary_cache, city, False) + return cached_entry.get("payload") or {} + return await run_in_threadpool(legacy_routes._refresh_city_summary_cache, city, False) + + +async def get_city_detail_aggregate_payload( + request: Request, + name: str, + *, + force_refresh: bool = False, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, +) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + city = legacy_routes._normalize_city_or_404(name) + data = await run_in_threadpool(legacy_routes._analyze, city, force_refresh, True) + return await run_in_threadpool( + legacy_routes._build_city_detail_payload, + data, + market_slug, + target_date, + ) + + +async def get_city_market_scan_payload( + request: Request, + background_tasks: BackgroundTasks, + name: str, + *, + force_refresh: bool = False, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, + lite: bool = False, +) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + city = legacy_routes._normalize_city_or_404(name) + if force_refresh: + data = await run_in_threadpool(legacy_routes._refresh_city_market_cache, city, True) + cached_scan = legacy_routes._get_cached_market_scan_payload( + data, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + if cached_scan is not None: + return cached_scan + else: + cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "market", city) + if cached_entry: + data = cached_entry.get("payload") or {} + cached_scan = legacy_routes._get_cached_market_scan_payload( + data, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + if cached_scan is not None: + if not legacy_routes._market_analysis_cache_is_fresh(cached_entry): + legacy_routes._schedule_cache_refresh(background_tasks, kind="market", city=city) + return cached_scan + if legacy_routes._market_analysis_cache_is_fresh(cached_entry): + return await run_in_threadpool( + legacy_routes._refresh_market_scan_payload_from_cached_analysis, + city, + data, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + else: + legacy_routes._schedule_cache_refresh(background_tasks, kind="market", city=city) + else: + data = await run_in_threadpool(legacy_routes._refresh_city_market_cache, city, False) + cached_scan = legacy_routes._get_cached_market_scan_payload( + data, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + if cached_scan is not None: + return cached_scan + return await run_in_threadpool( + legacy_routes._build_city_market_scan_payload, + data, + market_slug, + target_date, + lite, + ) diff --git a/web/services/city_payloads.py b/web/services/city_payloads.py new file mode 100644 index 00000000..dad6500c --- /dev/null +++ b/web/services/city_payloads.py @@ -0,0 +1,277 @@ +"""City payload builders for API-facing response shapes.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from src.analysis.settlement_rounding import apply_city_settlement +from web.core import _is_excluded_model_name, _market_layer, _sf + +TURKISH_MGM_CITIES = {"ankara", "istanbul"} + + +def build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: + return { + "name": data.get("name"), + "display_name": data.get("display_name"), + "icao": data.get("risk", {}).get("icao"), + "utc_offset_seconds": data.get("utc_offset_seconds"), + "local_time": data.get("local_time"), + "temp_symbol": data.get("temp_symbol"), + "current": { + "temp": data.get("current", {}).get("temp"), + "obs_time": data.get("current", {}).get("obs_time"), + "settlement_source": data.get("current", {}).get("settlement_source"), + "settlement_source_label": data.get("current", {}).get("settlement_source_label"), + }, + "deb": {"prediction": data.get("deb", {}).get("prediction")}, + "deviation_monitor": data.get("deviation_monitor") or {}, + "risk": { + "level": data.get("risk", {}).get("level"), + "warning": data.get("risk", {}).get("warning"), + }, + "updated_at": data.get("updated_at"), + } + + +def build_city_market_scan_payload( + data: Dict[str, Any], + market_slug: Optional[str] = None, + target_date: Optional[str] = None, + lite: bool = False, + scan_filters: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + city = str(data.get("name") or "").strip().lower() + local_date = str(data.get("local_date") or "").strip() + requested_date = str(target_date or "").strip() + selected_date = requested_date or local_date + + multi_model_daily = data.get("multi_model_daily") or {} + selected_daily = ( + multi_model_daily.get(selected_date) + if isinstance(multi_model_daily, dict) + else None + ) + if not isinstance(selected_daily, dict): + selected_daily = {} + selected_date = local_date + + distribution = selected_daily.get("probabilities") + if not isinstance(distribution, list) or not distribution: + distribution = data.get("probabilities", {}).get("distribution", []) or [] + distribution_all = selected_daily.get("probabilities_all") + if not isinstance(distribution_all, list) or not distribution_all: + distribution_all = data.get("probabilities", {}).get("distribution_all", []) or [] + if not distribution_all: + distribution_all = distribution + + model_map = selected_daily.get("models") or data.get("multi_model") or {} + if not isinstance(model_map, dict): + model_map = {} + + anchor_temp = None + anchor_model = None + for model_name, raw_value in model_map.items(): + value = _sf(raw_value) + if value is None: + continue + if anchor_temp is None or value > anchor_temp: + anchor_temp = value + anchor_model = str(model_name or "").strip() or None + + anchor_temp_c = anchor_temp + temp_symbol = str(data.get("temp_symbol") or "") + if anchor_temp_c is not None and "F" in temp_symbol.upper(): + anchor_temp_c = (anchor_temp_c - 32.0) * 5.0 / 9.0 + anchor_settlement = apply_city_settlement(city, anchor_temp_c) if anchor_temp_c is not None else None + + primary_bucket = None + if isinstance(distribution, list) and distribution: + ranked_buckets = [] + temp_symbol_upper = str(temp_symbol or "").upper() + max_primary_bucket_delta = 16.0 if "F" in temp_symbol_upper else 8.0 + for idx, row in enumerate(distribution_all): + if not isinstance(row, dict): + continue + bucket_value = _sf( + row.get("temp") + if row.get("temp") is not None + else row.get("value") + if row.get("value") is not None + else row.get("lower") + ) + if ( + anchor_temp is not None + and bucket_value is not None + and abs(float(bucket_value) - float(anchor_temp)) > max_primary_bucket_delta + ): + continue + bucket_prob = _sf(row.get("probability")) + prob_rank = bucket_prob if bucket_prob is not None else -1.0 + ranked_buckets.append((-prob_rank, idx, row)) + if ranked_buckets: + ranked_buckets.sort(key=lambda x: (x[0], x[1])) + primary_bucket = ranked_buckets[0][2] + elif anchor_temp is None: + primary_bucket = distribution[0] + + model_probability = None + if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None: + try: + raw_probability = float(primary_bucket.get("probability")) + model_probability = raw_probability / 100.0 if raw_probability > 1.0 else raw_probability + except Exception: + model_probability = None + + fallback_sparkline = [ + p.get("probability", 0) + for p in distribution_all[:8] + if isinstance(p, dict) + ] + current = data.get("current") or {} + selected_deb = selected_daily.get("deb") if isinstance(selected_daily.get("deb"), dict) else {} + current_deb = data.get("deb") if isinstance(data.get("deb"), dict) else {} + scan_context = { + "local_date": data.get("local_date"), + "local_time": data.get("local_time"), + "peak": data.get("peak") or {}, + "current_max_so_far": current.get("max_so_far"), + "current_temp": current.get("temp"), + "trend": data.get("trend") or {}, + "network_lead_signal": data.get("network_lead_signal") or {}, + "models": model_map, + "deb_prediction": selected_deb.get("prediction") or current_deb.get("prediction"), + } + market_scan = _market_layer.build_market_scan( + city=data.get("name"), + target_date=selected_date or data.get("local_date"), + temperature_bucket=primary_bucket if isinstance(primary_bucket, dict) else None, + model_probability=model_probability, + probability_distribution=distribution_all, + temp_symbol=temp_symbol, + fallback_sparkline=fallback_sparkline, + forced_market_slug=market_slug, + include_related_buckets=not lite, + scan_filters=scan_filters, + scan_context=scan_context, + ) + if isinstance(market_scan, dict): + market_scan["anchor_model"] = anchor_model + market_scan["anchor_high"] = anchor_temp + market_scan["anchor_settlement"] = anchor_settlement + market_scan["open_meteo_settlement"] = anchor_settlement + probabilities = data.get("probabilities") or {} + market_scan["probability_engine"] = str( + probabilities.get("engine") or "legacy" + ).strip() or "legacy" + market_scan["probability_calibration_mode"] = str( + probabilities.get("calibration_mode") or "legacy" + ).strip() or "legacy" + return { + "market_scan": market_scan, + "selected_date": selected_date or data.get("local_date"), + "fetched_at": data.get("updated_at"), + } + + +def build_city_detail_payload( + data: Dict[str, Any], + market_slug: Optional[str] = None, + target_date: Optional[str] = None, +) -> Dict[str, Any]: + market_payload = build_city_market_scan_payload( + data, + market_slug=market_slug, + target_date=target_date, + ) + market_scan = market_payload.get("market_scan") + return { + "city": data.get("name"), + "fetched_at": data.get("updated_at"), + "overview": { + "name": data.get("name"), + "display_name": data.get("display_name"), + "icao": data.get("risk", {}).get("icao"), + "airport": data.get("risk", {}).get("airport"), + "lat": data.get("lat"), + "lon": data.get("lon"), + "local_time": data.get("local_time"), + "local_date": data.get("local_date"), + "temp_symbol": data.get("temp_symbol"), + "current_temp": data.get("current", {}).get("temp"), + "settlement_source": data.get("current", {}).get("settlement_source"), + "settlement_source_label": data.get("current", {}).get("settlement_source_label"), + "settlement_station": data.get("settlement_station") or {}, + "deb_prediction": data.get("deb", {}).get("prediction"), + "risk_level": data.get("risk", {}).get("level"), + "risk_warning": data.get("risk", {}).get("warning"), + "updated_at": data.get("updated_at"), + }, + "official": { + "available": bool(data.get("current", {}).get("temp") is not None), + "metar": { + "observation_time": data.get("airport_current", {}).get("obs_time"), + "obs_age_min": data.get("airport_current", {}).get("obs_age_min"), + "report_time": data.get("airport_current", {}).get("report_time"), + "receipt_time": data.get("airport_current", {}).get("receipt_time"), + "raw_metar": data.get("airport_current", {}).get("raw_metar"), + "current": data.get("airport_current") or {}, + }, + "taf": data.get("taf") or {}, + "weather_gov": {}, + "mgm": data.get("mgm") or {}, + "mgm_nearby": data.get("mgm_nearby") or [], + "nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"), + "airport_primary": data.get("airport_primary") or {}, + "airport_primary_today_obs": data.get("airport_primary_today_obs") or [], + "official_nearby": data.get("official_nearby") or [], + "official_network_source": data.get("official_network_source"), + "official_network_status": data.get("official_network_status") or {}, + "network_lead_signal": data.get("network_lead_signal") or {}, + "network_spread_signal": data.get("network_spread_signal") or {}, + "center_station_candidate": data.get("center_station_candidate"), + "airport_vs_network_delta": data.get("airport_vs_network_delta"), + }, + "timeseries": { + "metar_recent_obs": data.get("metar_recent_obs") or [], + "metar_today_obs": data.get("metar_today_obs") or [], + "settlement_today_obs": data.get("settlement_today_obs") or [], + "hourly": data.get("hourly") or {}, + "mgm_hourly": (data.get("mgm") or {}).get("hourly", []), + "forecast_daily": (data.get("forecast") or {}).get("daily", []), + }, + "models": { + k: v + for k, v in (data.get("multi_model") or {}).items() + if not _is_excluded_model_name(k) + }, + "deb": data.get("deb") or {}, + "multi_model_daily": data.get("multi_model_daily") or {}, + "probabilities": data.get("probabilities") or {"mu": None, "distribution": []}, + "dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []}, + "intraday_meteorology": data.get("intraday_meteorology") + or _build_intraday_meteorology(data), + "vertical_profile_signal": data.get("vertical_profile_signal") or {}, + "taf": data.get("taf") or {}, + "market_scan": market_scan, + "risk": data.get("risk"), + "settlement_station": data.get("settlement_station") or {}, + "airport_primary": data.get("airport_primary") or {}, + "official_nearby": data.get("official_nearby") or [], + "official_network_source": data.get("official_network_source"), + "official_network_status": data.get("official_network_status") or {}, + "network_lead_signal": data.get("network_lead_signal") or {}, + "network_spread_signal": data.get("network_spread_signal") or {}, + "center_station_candidate": data.get("center_station_candidate"), + "airport_vs_network_delta": data.get("airport_vs_network_delta"), + "airport_current": data.get("airport_current") or {}, + "nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"), + "ai_analysis": data.get("ai_analysis") or "", + "errors": {}, + } + + +def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: + from web.analysis_service import _build_intraday_meteorology as build_intraday + + return build_intraday(data) diff --git a/web/services/city_runtime.py b/web/services/city_runtime.py new file mode 100644 index 00000000..8258d2f5 --- /dev/null +++ b/web/services/city_runtime.py @@ -0,0 +1,804 @@ +from __future__ import annotations + +import os +import time +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, HTTPException +from loguru import logger + +from src.analysis.deb_algorithm import load_history +from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day +from src.database.db_manager import DBManager +from src.database.runtime_state import ( + DailyRecordRepository, + STATE_STORAGE_SQLITE, + TrainingFeatureRecordRepository, + TruthRecordRepository, + get_state_storage_mode, +) +from src.analysis.settlement_rounding import apply_city_settlement +from src.data_collection.country_networks import get_country_network_provider # noqa: F401 - compatibility export for transitional routers +from src.data_collection.city_registry import ALIASES +from src.data_collection.city_time import get_city_utc_offset_seconds # noqa: F401 - compatibility export for transitional routers +from web.analysis_service import ( + _analyze, + _analyze_summary, + _build_city_detail_payload, # noqa: F401 - compatibility export for tests and transitional routers + _build_city_market_scan_payload, + _build_city_summary_payload, +) +from web.scan_terminal_service import ( + build_scan_city_ai_forecast_payload, # noqa: F401 - compatibility export for tests and transitional routers + build_scan_terminal_ai_payload, # noqa: F401 - compatibility export for tests and transitional routers + build_scan_terminal_payload, # noqa: F401 - compatibility export for tests and transitional routers + stream_scan_city_ai_forecast_payload, # noqa: F401 - compatibility export for tests and transitional routers +) +from web.core import ( + CITIES, + CITY_REGISTRY, # noqa: F401 - compatibility export for tests and transitional routers + CITY_RISK_PROFILES, # noqa: F401 - compatibility export for tests and transitional routers + PAYMENT_CHECKOUT, # noqa: F401 - compatibility export for tests and transitional routers + PaymentCheckoutError, # noqa: F401 - compatibility export for tests and transitional routers + SETTLEMENT_SOURCE_LABELS, + SUPABASE_ENTITLEMENT, # noqa: F401 - compatibility export for tests and transitional routers + ConfirmPaymentTxRequest, # noqa: F401 - compatibility export for tests and transitional routers + CreatePaymentIntentRequest, # noqa: F401 - compatibility export for tests and transitional routers + GrantPointsRequest, # noqa: F401 - compatibility export for tests and transitional routers + SubmitPaymentTxRequest, # noqa: F401 - compatibility export for tests and transitional routers + WalletChallengeRequest, # noqa: F401 - compatibility export for tests and transitional routers + WalletUnbindRequest, # noqa: F401 - compatibility export for tests and transitional routers + WalletVerifyRequest, # noqa: F401 - compatibility export for tests and transitional routers + _ENTITLEMENT_GUARD_ENABLED, # noqa: F401 - compatibility export for tests and transitional routers + _SUPABASE_AUTH_REQUIRED, # noqa: F401 - compatibility export for tests and transitional routers + _assert_entitlement, # noqa: F401 - compatibility export for tests and transitional routers + _bind_optional_supabase_identity, # noqa: F401 - compatibility export for tests and transitional routers + _require_ops_admin, # noqa: F401 - compatibility export for tests and transitional routers + _require_supabase_identity, # noqa: F401 - compatibility export for tests and transitional routers + _resolve_auth_points, # noqa: F401 - compatibility export for tests and transitional routers + _resolve_weekly_profile, # noqa: F401 - compatibility export for tests and transitional routers + _sf, + _is_excluded_model_name, +) + +router = APIRouter() +_CACHE_DB = DBManager() + +_DEB_RECENT_LOOKBACK = 7 +_DEB_RECENT_MIN_SAMPLES = 3 +_daily_record_repo = DailyRecordRepository() +_truth_record_repo = TruthRecordRepository() +_training_feature_repo = TrainingFeatureRecordRepository() + +TRACKABLE_ANALYTICS_EVENTS = { + "signup_completed", + "dashboard_active", + "paywall_feature_clicked", + "paywall_viewed", + "checkout_started", + "checkout_succeeded", +} + +DEFAULT_PREWARM_CITIES = [ + "ankara", + "istanbul", + "shanghai", + "beijing", + "shenzhen", + "guangzhou", + "qingdao", + "wuhan", + "chengdu", + "chongqing", + "hong kong", + "taipei", + "singapore", + "tokyo", + "seoul", + "busan", + "london", + "paris", + "madrid", +] +HISTORY_PREVIEW_DAY_LIMIT = 21 +ASIA_CORE_CITIES = [ + "hong kong", + "taipei", + "tokyo", + "seoul", + "busan", + "shanghai", + "beijing", + "guangzhou", + "qingdao", + "shenzhen", + "chongqing", + "chengdu", + "singapore", + "kuala lumpur", + "jakarta", +] +EUROPE_CORE_CITIES = [ + "istanbul", + "ankara", + "moscow", + "tel aviv", + "london", + "paris", + "madrid", + "milan", + "warsaw", + "amsterdam", + "helsinki", +] +US_CORE_CITIES = [ + "new york", + "los angeles", + "san francisco", + "austin", + "houston", + "chicago", + "dallas", + "miami", + "atlanta", + "seattle", +] +CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "1800"))) +CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "1800"))) +CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "1800"))) +CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "1800"))) +MARKET_SCAN_PAYLOAD_TTL_SEC = max( + 5, + int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")), +) +CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max( + 60, + int(os.getenv("POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC", "1800")), +) +CACHE_REFRESH_LOCK_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CACHE_REFRESH_LOCK_TTL_SEC", "120"))) + + +def _city_cache_is_fresh(entry: Optional[dict], ttl_sec: int) -> bool: + if not isinstance(entry, dict): + return False + updated_at_ts = float(entry.get("updated_at_ts") or 0.0) + if updated_at_ts <= 0: + return False + return (time.time() - updated_at_ts) < float(ttl_sec) + + +def _market_analysis_cache_is_fresh(entry: Optional[dict]) -> bool: + if not isinstance(entry, dict): + return False + payload = entry.get("payload") or {} + if isinstance(payload, dict): + cached_at_ts = float(payload.get("market_analysis_cached_at_ts") or 0.0) + if cached_at_ts > 0: + return (time.time() - cached_at_ts) < float(CITY_MARKET_CACHE_TTL_SEC) + return _city_cache_is_fresh(entry, CITY_MARKET_CACHE_TTL_SEC) + + +def _market_scan_cache_key( + data: dict, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, + lite: bool = False, +) -> str: + local_date = str(data.get("local_date") or "").strip() + requested_date = str(target_date or "").strip() + selected_date = requested_date or local_date + multi_model_daily = data.get("multi_model_daily") or {} + if requested_date and isinstance(multi_model_daily, dict) and requested_date not in multi_model_daily: + selected_date = local_date + normalized_slug = str(market_slug or "").strip().lower() + return f"{selected_date}|{normalized_slug}|lite={1 if lite else 0}" + + +def _attach_market_scan_payload( + payload: dict, + *, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, + lite: bool = False, +) -> dict: + if not isinstance(payload, dict): + return payload + scan_payload = _build_city_market_scan_payload( + payload, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + now_ts = time.time() + payload["market_scan_payload"] = scan_payload + payload["market_scan_updated_at"] = datetime.now().isoformat() + payload["market_scan_updated_at_ts"] = now_ts + payload["market_scan_cache_key"] = _market_scan_cache_key( + payload, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + return payload + + +def _get_cached_market_scan_payload( + payload: dict, + *, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, + lite: bool = False, +) -> Optional[dict]: + if not isinstance(payload, dict): + return None + scan_payload = payload.get("market_scan_payload") + if not isinstance(scan_payload, dict): + return None + expected_key = _market_scan_cache_key( + payload, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + cached_key = str(payload.get("market_scan_cache_key") or "") + if cached_key != expected_key: + return None + updated_at_ts = float(payload.get("market_scan_updated_at_ts") or 0.0) + if updated_at_ts <= 0: + return None + if (time.time() - updated_at_ts) >= float(MARKET_SCAN_PAYLOAD_TTL_SEC): + return None + return scan_payload + + +def _refresh_market_scan_payload_from_cached_analysis( + city: str, + payload: dict, + *, + market_slug: Optional[str] = None, + target_date: Optional[str] = None, + lite: bool = False, +) -> dict: + _attach_market_scan_payload( + payload, + market_slug=market_slug, + target_date=target_date, + lite=lite, + ) + _CACHE_DB.set_city_cache( + "market", + city, + payload, + version="v1", + source_fingerprint=f"{city}:market", + ) + return payload.get("market_scan_payload") or {} + + +def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict: + data = _analyze_summary(city, force_refresh=force_refresh) + payload = _build_city_summary_payload(data) + _CACHE_DB.set_city_cache( + "summary", + city, + payload, + version="v1", + source_fingerprint=f"{city}:summary", + ) + return payload + + +def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict: + payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="panel") + _CACHE_DB.set_city_cache( + "panel", + city, + payload, + version="v1", + source_fingerprint=f"{city}:panel", + ) + return payload + + +def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict: + payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="nearby") + _CACHE_DB.set_city_cache( + "nearby", + city, + payload, + version="v1", + source_fingerprint=f"{city}:nearby", + ) + return payload + + +def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict: + payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="market") + now_ts = time.time() + payload["market_analysis_cached_at"] = datetime.now().isoformat() + payload["market_analysis_cached_at_ts"] = now_ts + _attach_market_scan_payload(payload) + _CACHE_DB.set_city_cache( + "market", + city, + payload, + version="v1", + source_fingerprint=f"{city}:market", + ) + return payload + + +def _build_history_model_reference( + *, + forecasts: dict, + actual: object, + deb: object, +) -> dict: + """Expose the archived model snapshot as reference evidence, not truth.""" + actual_value = _sf(actual) + deb_value = _sf(deb) + entries = [] + for model_name, model_value in (forecasts or {}).items(): + if _is_excluded_model_name(str(model_name)): + continue + value = _sf(model_value) + if value is None: + continue + error = abs(value - actual_value) if actual_value is not None else None + entries.append( + { + "model": str(model_name), + "value": round(value, 1), + "error": round(error, 1) if error is not None else None, + "participates_in_deb": True, + } + ) + entries.sort( + key=lambda row: ( + row["error"] is None, + row["error"] if row["error"] is not None else 999, + row["model"], + ) + ) + deb_error = abs(deb_value - actual_value) if deb_value is not None and actual_value is not None else None + return { + "available": bool(entries), + "truth_layer": "settlement_actual", + "reference_layer": "archived_model_snapshot", + "deb": { + "value": round(deb_value, 1) if deb_value is not None else None, + "error": round(deb_error, 1) if deb_error is not None else None, + }, + "models": entries, + "model_count": len(entries), + } + + +def _build_city_history_payload(city: str, include_records: bool = False) -> dict: + source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower() + truth_rows = _truth_record_repo.load_city(city) + feature_rows = _training_feature_repo.load_city(city) + + if not truth_rows and not feature_rows: + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + history_file = os.path.join(project_root, "data", "daily_records.json") + data = load_history(history_file) + city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {} + else: + all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys())) + city_data = {} + for day in all_dates: + record: dict[str, object] = {} + truth = truth_rows.get(day) or {} + features = feature_rows.get(day) or {} + if truth.get("actual_high") is not None: + record["actual_high"] = truth.get("actual_high") + record["settlement_source"] = truth.get("settlement_source") + record["settlement_station_code"] = truth.get("settlement_station_code") + record["settlement_station_label"] = truth.get("settlement_station_label") + record["truth_version"] = truth.get("truth_version") + record["updated_by"] = truth.get("updated_by") + record["truth_updated_at"] = truth.get("truth_updated_at") + if isinstance(features, dict): + if features.get("deb_prediction") is not None: + record["deb_prediction"] = features.get("deb_prediction") + if features.get("mu") is not None: + record["mu"] = features.get("mu") + if isinstance(features.get("forecasts"), dict): + record["forecasts"] = features.get("forecasts") + city_data[day] = record + + if not city_data: + return { + "history": [], + "mode": "full" if include_records else "preview", + "has_more": False, + "full_count": 0, + "preview_count": 0, + "settlement_source": source, + "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()), + } + + all_days = sorted(city_data.keys()) + selected_days = all_days if include_records else all_days[-HISTORY_PREVIEW_DAY_LIMIT:] + out = [] + for day in selected_days: + rec = city_data.get(day, {}) + if not isinstance(rec, dict): + rec = {} + + act = rec.get("actual_high") + deb = rec.get("deb_prediction") + mu = rec.get("mu") + snapshots = load_snapshot_rows_for_day(city, day) + peak_ref = _build_peak_minus_12h_reference( + actual_high=act, + snapshots=snapshots, + ) + forecasts_raw = rec.get("forecasts", {}) or {} + forecasts = {} + if isinstance(forecasts_raw, dict): + for model_name, model_value in forecasts_raw.items(): + if _is_excluded_model_name(str(model_name)): + continue + fv = _sf(model_value) + forecasts[str(model_name)] = fv if fv is not None else None + forecasts = _merge_missing_history_forecasts_from_snapshots( + forecasts, + snapshots, + ) + model_reference = _build_history_model_reference( + forecasts=forecasts, + actual=act, + deb=deb, + ) + mgm = forecasts.get("MGM") + out.append( + { + "date": day, + "actual": float(act) if act is not None else None, + "deb": float(deb) if deb is not None else None, + "mu": float(mu) if mu is not None else None, + "mgm": float(mgm) if mgm is not None else None, + "forecasts": forecasts, + "model_reference": model_reference, + "settlement_source": rec.get("settlement_source"), + "settlement_station_code": rec.get("settlement_station_code"), + "settlement_station_label": rec.get("settlement_station_label"), + "truth_version": rec.get("truth_version"), + "updated_by": rec.get("updated_by"), + "truth_updated_at": rec.get("truth_updated_at"), + "actual_peak_time": peak_ref.get("actual_peak_time"), + "deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"), + "deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"), + "deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"), + } + ) + + return { + "history": out, + "mode": "full" if include_records else "preview", + "has_more": len(all_days) > len(selected_days), + "full_count": len(all_days), + "preview_count": len(out), + "settlement_source": source, + "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()), + } + + +def _refresh_city_history_preview_cache(city: str) -> dict: + payload = _build_city_history_payload(city, include_records=False) + _CACHE_DB.set_city_cache( + "history_preview", + city, + payload, + version="v1", + source_fingerprint=f"{city}:history_preview", + ) + return payload + + +def _schedule_cache_refresh( + background_tasks: BackgroundTasks, + *, + kind: str, + city: str, + force_refresh: bool = False, +) -> bool: + normalized_kind = str(kind or "").strip().lower() + normalized_city = str(city or "").strip().lower() + if normalized_kind not in {"summary", "panel", "nearby", "market", "history_preview"} or not normalized_city: + return False + cache_key = f"city:{normalized_kind}:{normalized_city}" + owner = _CACHE_DB.acquire_cache_refresh_lock( + cache_key, + ttl_sec=CACHE_REFRESH_LOCK_TTL_SEC, + ) + if not owner: + return False + + def _runner() -> None: + try: + if normalized_kind == "summary": + _refresh_city_summary_cache(normalized_city, force_refresh=force_refresh) + elif normalized_kind == "panel": + _refresh_city_panel_cache(normalized_city, force_refresh=force_refresh) + elif normalized_kind == "nearby": + _refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh) + elif normalized_kind == "history_preview": + _refresh_city_history_preview_cache(normalized_city) + else: + _refresh_city_market_cache(normalized_city, force_refresh=force_refresh) + except Exception as exc: + logger.warning( + "cache refresh failed kind={} city={} force_refresh={}: {}", + normalized_kind, + normalized_city, + force_refresh, + exc, + ) + finally: + _CACHE_DB.release_cache_refresh_lock(cache_key, owner) + + background_tasks.add_task(_runner) + return True + + +def _parse_snapshot_dt(value: object) -> Optional[datetime]: + raw = str(value or "").strip() + if not raw: + return None + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return None + + +def _build_peak_minus_12h_reference( + *, + actual_high: object, + snapshots: list[dict], +) -> dict: + actual = _sf(actual_high) + if actual is None or not snapshots: + return {} + + tolerance = 0.11 + normalized = [] + for row in snapshots: + if not isinstance(row, dict): + continue + dt = _parse_snapshot_dt(row.get("timestamp")) + if dt is None: + continue + normalized.append( + { + "dt": dt, + "max_so_far": _sf(row.get("max_so_far")), + "deb_prediction": _sf(row.get("deb_prediction")), + } + ) + if not normalized: + return {} + + peak_row = next( + ( + row + for row in normalized + if row["max_so_far"] is not None and row["max_so_far"] >= actual - tolerance + ), + None, + ) + if peak_row is None: + return {} + + peak_dt = peak_row["dt"] + anchor_dt = peak_dt - timedelta(hours=12) + anchor_row = None + for row in normalized: + if row["dt"] <= anchor_dt and row["deb_prediction"] is not None: + anchor_row = row + elif row["dt"] > anchor_dt: + break + + peak_time = peak_dt.strftime("%H:%M") + result = { + "actual_peak_time": peak_time, + } + if anchor_row and anchor_row["deb_prediction"] is not None: + deb_value = float(anchor_row["deb_prediction"]) + result.update( + { + "deb_at_peak_minus_12h": deb_value, + "deb_at_peak_minus_12h_time": anchor_row["dt"].strftime("%H:%M"), + "deb_at_peak_minus_12h_error": round(deb_value - actual, 1), + } + ) + return result + + +def _merge_missing_history_forecasts_from_snapshots( + forecasts: dict, + snapshots: list[dict], +) -> dict: + merged = dict(forecasts or {}) + if not snapshots: + return merged + + fallback_values: dict[str, Optional[float]] = {} + for row in snapshots: + if not isinstance(row, dict): + continue + multi_model = row.get("multi_model") or {} + if not isinstance(multi_model, dict): + continue + for model_name, model_value in multi_model.items(): + model_key = str(model_name or "").strip() + if not model_key or _is_excluded_model_name(model_key): + continue + parsed = _sf(model_value) + if parsed is not None: + fallback_values[model_key] = parsed + + for model_name, model_value in fallback_values.items(): + existing = _sf(merged.get(model_name)) + if existing is None: + merged[model_name] = model_value + return merged + + +def _normalize_city_or_404(name: str) -> str: + city = name.lower().strip().replace("-", " ") + city = ALIASES.get(city, city) + if city not in CITIES: + raise HTTPException(404, detail=f"Unknown city: {city}") + return city + + +def _normalize_city_list(raw: Optional[str]) -> list[str]: + if not raw: + return list(DEFAULT_PREWARM_CITIES) + out: list[str] = [] + for part in str(raw).split(","): + city = str(part or "").strip().lower().replace("-", " ") + if not city: + continue + city = ALIASES.get(city, city) + if city in CITIES and city not in out: + out.append(city) + return out + + +def _select_priority_city_batches(client_timezone: Optional[str]) -> dict[str, object]: + tz = str(client_timezone or "").strip() + normalized = tz.lower() + if normalized.startswith("america/"): + primary = list(US_CORE_CITIES) + secondary = [] + region = "america" + elif normalized.startswith("europe/"): + primary = list(EUROPE_CORE_CITIES) + secondary = list(ASIA_CORE_CITIES) + region = "europe" + elif normalized.startswith("asia/") or normalized.startswith("australia/") or normalized.startswith("pacific/"): + primary = list(ASIA_CORE_CITIES) + secondary = list(EUROPE_CORE_CITIES) + region = "asia" + else: + primary = list(ASIA_CORE_CITIES) + secondary = list(EUROPE_CORE_CITIES) + region = "default" + return { + "region": region, + "timezone": tz or None, + "primary": primary, + "secondary": secondary, + } + + +def _history_file_path() -> str: + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(project_root, "data", "daily_records.json") + + +def _build_recent_deb_performance_index( + history_data: Optional[dict] = None, + *, + lookback: int = _DEB_RECENT_LOOKBACK, + min_samples: int = _DEB_RECENT_MIN_SAMPLES, +) -> dict[str, dict[str, object]]: + index: dict[str, dict[str, object]] = {} + today = datetime.utcnow().strftime("%Y-%m-%d") + settled_by_city: dict[str, list[tuple[str, float, float]]] = {} + + if isinstance(history_data, dict): + for city_name, rows in history_data.items(): + if not isinstance(rows, dict): + continue + settled: list[tuple[str, float, float]] = [] + for date_key in sorted(rows.keys(), reverse=True): + if date_key >= today: + continue + record = rows.get(date_key) or {} + if not isinstance(record, dict): + continue + actual = _sf(record.get("actual_high")) + deb_prediction = _sf(record.get("deb_prediction")) + if actual is None or deb_prediction is None: + continue + settled.append((date_key, actual, deb_prediction)) + if len(settled) >= max(lookback, 1): + break + settled_by_city[str(city_name).strip().lower()] = settled + elif get_state_storage_mode() == STATE_STORAGE_SQLITE: + recent_rows = _daily_record_repo.load_recent_settled_rows( + before_date=today, + per_city_limit=max(lookback, 1), + ) + for city_name, rows in recent_rows.items(): + settled: list[tuple[str, float, float]] = [] + for row in rows: + actual = _sf(row.get("actual_high")) + deb_prediction = _sf(row.get("deb_prediction")) + date_key = str(row.get("target_date") or "").strip() + if not date_key or actual is None or deb_prediction is None: + continue + settled.append((date_key, actual, deb_prediction)) + settled_by_city[str(city_name).strip().lower()] = settled + else: + data = load_history(_history_file_path()) + if not isinstance(data, dict): + return index + for city_name, rows in data.items(): + if not isinstance(rows, dict): + continue + settled: list[tuple[str, float, float]] = [] + for date_key in sorted(rows.keys(), reverse=True): + if date_key >= today: + continue + record = rows.get(date_key) or {} + if not isinstance(record, dict): + continue + actual = _sf(record.get("actual_high")) + deb_prediction = _sf(record.get("deb_prediction")) + if actual is None or deb_prediction is None: + continue + settled.append((date_key, actual, deb_prediction)) + if len(settled) >= max(lookback, 1): + break + settled_by_city[str(city_name).strip().lower()] = settled + + for city_name, settled in settled_by_city.items(): + if not settled: + continue + + hit_count = 0 + abs_errors: list[float] = [] + for _, actual, deb_prediction in settled: + abs_errors.append(abs(deb_prediction - actual)) + if apply_city_settlement(city_name, actual) == apply_city_settlement(city_name, deb_prediction): + hit_count += 1 + + sample_count = len(settled) + hit_rate = (hit_count / sample_count) if sample_count > 0 else None + if sample_count < min_samples: + tier = "other" + elif hit_rate is not None and hit_rate >= 0.67: + tier = "high" + elif hit_rate is not None and hit_rate >= 0.34: + tier = "medium" + else: + tier = "low" + + index[str(city_name).strip().lower()] = { + "tier": tier, + "sample_count": sample_count, + "hit_rate": round(hit_rate, 4) if hit_rate is not None else None, + "mae": round(sum(abs_errors) / sample_count, 3) if sample_count > 0 else None, + "last_date": settled[0][0] if settled else None, + } + return index + +__all__ = [name for name in globals() if not (name.startswith('__') and name.endswith('__'))] + diff --git a/web/services/groq_commentary.py b/web/services/groq_commentary.py new file mode 100644 index 00000000..b97602e4 --- /dev/null +++ b/web/services/groq_commentary.py @@ -0,0 +1,223 @@ +"""Groq-backed bilingual commentary enrichment.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import threading +import time +from typing import Any, Dict, Optional + +import httpx +from loguru import logger + +_GROQ_COMMENTARY_CACHE_LOCK = threading.Lock() +_GROQ_COMMENTARY_CACHE: Dict[str, Dict[str, Any]] = {} +_GROQ_COMMENTARY_CACHE_TTL_SEC = int( + os.getenv("POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC", "1800") +) + + +def groq_commentary_enabled() -> bool: + enabled = str( + os.getenv("POLYWEATHER_GROQ_COMMENTARY_ENABLED", "false") + ).strip().lower() + api_key = str(os.getenv("GROQ_API_KEY") or "").strip() + return enabled in {"1", "true", "yes", "on"} and bool(api_key) + + +def clean_commentary_text(value: Any, *, limit: int = 240) -> str: + text = str(value or "").strip() + if not text: + return "" + text = re.sub(r"\s+", " ", text) + return text[:limit].strip() + + +def build_groq_commentary_context(result: Dict[str, Any]) -> Dict[str, Any]: + dynamic = result.get("dynamic_commentary") or {} + vertical = result.get("vertical_profile_signal") or {} + taf_signal = ((result.get("taf") or {}).get("signal") or {}) if isinstance(result.get("taf"), dict) else {} + network = result.get("network_lead_signal") or {} + peak = result.get("peak") or {} + current = result.get("current") or {} + airport_primary = result.get("airport_primary") or {} + notes = dynamic.get("notes") if isinstance(dynamic.get("notes"), list) else [] + compact_notes = [clean_commentary_text(item, limit=180) for item in notes] + compact_notes = [item for item in compact_notes if item][:4] + return { + "city": result.get("display_name") or result.get("name"), + "local_date": result.get("local_date"), + "local_time": result.get("local_time"), + "temp_symbol": result.get("temp_symbol"), + "current_temp": current.get("temp"), + "day_high_so_far": current.get("max_so_far"), + "airport_anchor_temp": airport_primary.get("temp"), + "airport_vs_network_delta": result.get("airport_vs_network_delta"), + "peak_hours": peak.get("hours") or [], + "peak_status": peak.get("status"), + "network_lead_status": network.get("status"), + "network_lead_note": clean_commentary_text(network.get("note"), limit=180), + "rules_summary": clean_commentary_text(dynamic.get("summary"), limit=260), + "rules_notes": compact_notes, + "upper_air_summary_zh": clean_commentary_text(vertical.get("summary_zh"), limit=260), + "upper_air_summary_en": clean_commentary_text(vertical.get("summary_en"), limit=260), + "taf_summary_zh": clean_commentary_text(taf_signal.get("summary_zh"), limit=220), + "taf_summary_en": clean_commentary_text(taf_signal.get("summary_en"), limit=220), + "taf_peak_window": clean_commentary_text(taf_signal.get("peak_window"), limit=80), + } + + +def normalize_groq_commentary_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + def _headline(value: Any, fallback: str) -> str: + text = clean_commentary_text(value, limit=90) + return text or fallback + + def _bullets(value: Any) -> list[str]: + items = value if isinstance(value, list) else [] + cleaned = [clean_commentary_text(item, limit=120) for item in items] + cleaned = [item for item in cleaned if item] + return cleaned[:3] + + zh_headline = _headline(payload.get("headline_zh"), "结构信号以现有规则结论为主。") + en_headline = _headline(payload.get("headline_en"), "Structural read stays anchored to the existing rule-based signal.") + zh_bullets = _bullets(payload.get("bullets_zh")) + en_bullets = _bullets(payload.get("bullets_en")) + while len(zh_bullets) < 3: + zh_bullets.append("继续结合当前节奏、边界风险和峰值窗口判断。") + while len(en_bullets) < 3: + en_bullets.append("Keep the read anchored to pace, boundary risk, and the peak window.") + return { + "headline_zh": zh_headline, + "headline_en": en_headline, + "bullets_zh": zh_bullets[:3], + "bullets_en": en_bullets[:3], + "source": "groq", + } + + +def request_groq_commentary(context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + api_key = str(os.getenv("GROQ_API_KEY") or "").strip() + if not api_key: + return None + model = str(os.getenv("POLYWEATHER_GROQ_COMMENTARY_MODEL") or "openai/gpt-oss-20b").strip() + timeout_sec = float(os.getenv("POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC", "8")) + payload = { + "model": model, + "temperature": 0.2, + "max_tokens": 400, + "messages": [ + { + "role": "system", + "content": ( + "You rewrite weather-market structure commentary. " + "Never invent facts. Use only the provided context. " + "Return concise bilingual output for a dashboard: " + "one headline and exactly three bullets in Chinese, and the same in English. " + "Keep every bullet actionable and short." + ), + }, + { + "role": "user", + "content": json.dumps(context, ensure_ascii=False), + }, + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "polyweather_structure_commentary", + "strict": True, + "schema": { + "type": "object", + "additionalProperties": False, + "properties": { + "headline_zh": {"type": "string"}, + "bullets_zh": { + "type": "array", + "items": {"type": "string"}, + "minItems": 3, + "maxItems": 3, + }, + "headline_en": {"type": "string"}, + "bullets_en": { + "type": "array", + "items": {"type": "string"}, + "minItems": 3, + "maxItems": 3, + }, + }, + "required": [ + "headline_zh", + "bullets_zh", + "headline_en", + "bullets_en", + ], + }, + }, + }, + } + with httpx.Client(timeout=timeout_sec) as client: + response = client.post( + "https://api.groq.com/openai/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + body = response.json() + content = ( + (((body.get("choices") or [{}])[0]).get("message") or {}).get("content") + if isinstance(body, dict) + else None + ) + if not content: + return None + try: + return normalize_groq_commentary_payload(json.loads(str(content))) + except Exception: + logger.warning("Groq commentary returned non-JSON payload") + return None + + +def maybe_enrich_dynamic_commentary_with_groq( + city: str, + result: Dict[str, Any], +) -> Dict[str, Any]: + dynamic = result.get("dynamic_commentary") or {} + if not groq_commentary_enabled(): + return dynamic + if dynamic.get("headline_zh") and dynamic.get("bullets_zh"): + return dynamic + + context = build_groq_commentary_context(result) + if not context.get("rules_summary") and not context.get("rules_notes"): + return dynamic + + cache_key = hashlib.sha256( + json.dumps({"city": city, "context": context}, sort_keys=True, ensure_ascii=False).encode("utf-8") + ).hexdigest() + now = time.time() + with _GROQ_COMMENTARY_CACHE_LOCK: + cached = _GROQ_COMMENTARY_CACHE.get(cache_key) + if cached and now - float(cached.get("t") or 0) < _GROQ_COMMENTARY_CACHE_TTL_SEC: + merged = dict(dynamic) + merged.update(cached.get("payload") or {}) + return merged + + try: + enriched = request_groq_commentary(context) + except Exception as exc: + logger.warning("Groq commentary skipped for {}: {}", city, exc) + return dynamic + if not enriched: + return dynamic + + with _GROQ_COMMENTARY_CACHE_LOCK: + _GROQ_COMMENTARY_CACHE[cache_key] = {"t": now, "payload": enriched} + merged = dict(dynamic) + merged.update(enriched) + return merged diff --git a/web/services/ops_api.py b/web/services/ops_api.py new file mode 100644 index 00000000..94033c9e --- /dev/null +++ b/web/services/ops_api.py @@ -0,0 +1,228 @@ +"""Operations/admin API service functions.""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import HTTPException, Request + +from src.database.db_manager import DBManager +from web.core import GrantPointsRequest +import web.routes as legacy_routes + + +def _require_ops(request: Request) -> Dict[str, Any] | None: + legacy_routes._assert_entitlement(request) + return legacy_routes._require_ops_admin(request) + + +def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str, Any]: + _require_ops(request) + db = DBManager() + return {"users": db.search_users(q, limit=limit)} + + +def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, Any]: + _require_ops(request) + db = DBManager() + return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} + + +def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: + _require_ops(request) + db = DBManager() + if getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False): + try: + legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents( + limit=min(max(int(limit or 200), 20), 200) + ) + except Exception: + pass + subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(limit=limit) + subscription_user_ids = [str(item.get("user_id") or "") for item in subscriptions] + user_map = db.get_users_by_supabase_user_ids(subscription_user_ids) + unresolved_user_ids = [ + user_id + for user_id in subscription_user_ids + if str(user_id or "").strip().lower() + and not str( + (user_map.get(str(user_id).strip().lower(), {}) or {}).get("supabase_email") or "" + ).strip() + ] + auth_user_map = legacy_routes.SUPABASE_ENTITLEMENT.get_auth_users(unresolved_user_ids) + deduped: dict[str, dict] = {} + for item in subscriptions: + user_id = str(item.get("user_id") or "").strip().lower() + local_user = user_map.get(user_id, {}) + auth_user = auth_user_map.get(user_id, {}) + subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( + user_id, + respect_requirement=False, + bypass_cache=True, + ) + current_expires_at = item.get("expires_at") + total_expires_at = ( + subscription_window.get("total_expires_at") + if isinstance(subscription_window, dict) + else None + ) + queued_days = ( + int(subscription_window.get("queued_days") or 0) + if isinstance(subscription_window, dict) + else 0 + ) + queued_count = ( + int(subscription_window.get("queued_count") or 0) + if isinstance(subscription_window, dict) + else 0 + ) + row = { + "user_id": user_id, + "email": str(auth_user.get("email") or local_user.get("supabase_email") or ""), + "telegram_id": local_user.get("telegram_id"), + "username": local_user.get("username"), + "registered_at": local_user.get("created_at") or auth_user.get("created_at"), + "plan_code": item.get("plan_code"), + "starts_at": item.get("starts_at"), + "current_expires_at": current_expires_at, + "total_expires_at": total_expires_at or current_expires_at, + "expires_at": total_expires_at or current_expires_at, + "queued_days": queued_days, + "queued_count": queued_count, + } + existing = deduped.get(user_id) + existing_expires = str(existing.get("expires_at") or "") if existing else "" + current_expires = str(row.get("expires_at") or "") + if existing is None or current_expires > existing_expires: + deduped[user_id] = row + rows = sorted( + deduped.values(), + key=lambda item: str(item.get("expires_at") or ""), + ) + return {"memberships": rows} + + +def list_ops_payment_incidents( + request: Request, + limit: int = 50, + reason: str = "", + include_resolved: bool = False, +) -> Dict[str, Any]: + _require_ops(request) + db = DBManager() + incidents = db.list_payment_audit_events( + limit=max(1, min(int(limit or 50), 200)), + event_type="payment_intent_failed", + ) + normalized_reason = str(reason or "").strip().lower() + filtered = [] + for item in incidents: + payload = item.get("payload") if isinstance(item, dict) else {} + payload = payload if isinstance(payload, dict) else {} + item_reason = str(payload.get("reason") or "").strip().lower() + resolved_at = str(payload.get("resolved_at") or "").strip() + if normalized_reason and item_reason != normalized_reason: + continue + if not include_resolved and resolved_at: + continue + filtered.append(item) + return {"incidents": filtered} + + +def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, Any]: + admin = _require_ops(request) or {} + db = DBManager() + resolved = db.mark_payment_audit_event_resolved(event_id, str(admin.get("email") or "")) + if not resolved: + raise HTTPException(status_code=404, detail="payment_incident_not_found") + return {"ok": True, "incident": resolved} + + +def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]: + admin = _require_ops(request) or {} + db = DBManager() + result = db.grant_points_by_supabase_email(body.email, body.points) + result["operator_email"] = admin.get("email") + if not result.get("ok"): + reason = str(result.get("reason") or "grant_points_failed") + status_code = 404 if reason == "user_not_found" else 400 + raise HTTPException(status_code=status_code, detail=result) + return result + + +def get_ops_analytics_funnel(request: Request, days: int = 30) -> Dict[str, Any]: + _require_ops(request) + db = DBManager() + return db.get_app_analytics_funnel_summary(days=days) + + +def get_ops_truth_history( + request: Request, + city: str = "", + date_from: str = "", + date_to: str = "", + limit: int = 200, +) -> Dict[str, Any]: + _require_ops(request) + + truth_history = legacy_routes.TruthRecordRepository().load_all() + normalized_city = str(city or "").strip().lower() + normalized_from = str(date_from or "").strip() + normalized_to = str(date_to or "").strip() + max_limit = max(1, min(int(limit or 200), 1000)) + + rows = [] + for row_city, by_date in truth_history.items(): + if normalized_city and row_city != normalized_city: + continue + if not isinstance(by_date, dict): + continue + for target_date, payload in by_date.items(): + if normalized_from and str(target_date) < normalized_from: + continue + if normalized_to and str(target_date) > normalized_to: + continue + if not isinstance(payload, dict): + continue + rows.append( + { + "city": row_city, + "display_name": str( + (legacy_routes.CITY_REGISTRY.get(row_city) or {}).get("name") or row_city + ), + "target_date": str(target_date), + "actual_high": payload.get("actual_high"), + "settlement_source": payload.get("settlement_source"), + "settlement_station_code": payload.get("settlement_station_code"), + "settlement_station_label": payload.get("settlement_station_label"), + "truth_version": payload.get("truth_version"), + "updated_by": payload.get("updated_by"), + "truth_updated_at": payload.get("truth_updated_at"), + "is_final": payload.get("is_final"), + } + ) + + rows.sort(key=lambda item: (str(item["target_date"]), str(item["city"])), reverse=True) + filtered_count = len(rows) + rows = rows[:max_limit] + available_cities = [ + { + "city": city_id, + "name": str(info.get("name") or city_id), + } + for city_id, info in sorted( + legacy_routes.CITY_REGISTRY.items(), + key=lambda item: str(item[1].get("name") or item[0]), + ) + ] + return { + "items": rows, + "available_cities": available_cities, + "filters": { + "city": normalized_city or None, + "date_from": normalized_from or None, + "date_to": normalized_to or None, + "limit": max_limit, + }, + "filtered_count": filtered_count, + } diff --git a/web/services/payment_api.py b/web/services/payment_api.py new file mode 100644 index 00000000..eb6ebcd2 --- /dev/null +++ b/web/services/payment_api.py @@ -0,0 +1,179 @@ +"""Payment API service functions used by the payments router.""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import HTTPException, Request + +from src.database.db_manager import DBManager +from web.core import ( + ConfirmPaymentTxRequest, + CreatePaymentIntentRequest, + SubmitPaymentTxRequest, + WalletChallengeRequest, + WalletUnbindRequest, + WalletVerifyRequest, +) +import web.routes as legacy_routes + + +def _raise_payment_error(exc: Exception) -> None: + raise HTTPException( + status_code=getattr(exc, "status_code", 400), + detail=getattr(exc, "detail", str(exc)), + ) from exc + + +def _require_payment_identity(request: Request) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + return legacy_routes._require_supabase_identity(request) + + +def get_payment_config(request: Request) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + try: + return legacy_routes.PAYMENT_CHECKOUT.get_config_payload() + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def get_payment_runtime(request: Request) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + try: + db = DBManager() + return { + "checkout": legacy_routes.PAYMENT_CHECKOUT.get_config_payload(), + "rpc": legacy_routes.PAYMENT_CHECKOUT.get_rpc_runtime_status(), + "event_loop_state": db.get_payment_runtime_state("payment_event_loop") or {}, + "recent_audit_events": db.list_payment_audit_events(limit=20), + } + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def list_payment_wallets(request: Request) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + wallets = legacy_routes.PAYMENT_CHECKOUT.list_wallets(identity["user_id"]) + return { + "wallets": [wallet.__dict__ for wallet in wallets], + "chain_id": legacy_routes.PAYMENT_CHECKOUT.chain_id, + } + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def unbind_payment_wallet(request: Request, body: WalletUnbindRequest) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + return legacy_routes.PAYMENT_CHECKOUT.unbind_wallet( + user_id=identity["user_id"], + address=body.address, + ) + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def create_payment_wallet_challenge( + request: Request, + body: WalletChallengeRequest, +) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + return legacy_routes.PAYMENT_CHECKOUT.create_wallet_challenge( + user_id=identity["user_id"], + address=body.address, + ) + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def verify_payment_wallet( + request: Request, + body: WalletVerifyRequest, +) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + bound = legacy_routes.PAYMENT_CHECKOUT.verify_wallet_binding( + user_id=identity["user_id"], + address=body.address, + nonce=body.nonce, + signature=body.signature, + ) + return {"wallet": bound.__dict__} + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def create_payment_intent( + request: Request, + body: CreatePaymentIntentRequest, +) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + return legacy_routes.PAYMENT_CHECKOUT.create_intent( + user_id=identity["user_id"], + plan_code=body.plan_code, + payment_mode=body.payment_mode, + allowed_wallet=body.allowed_wallet, + token_address=body.token_address, + use_points=body.use_points, + points_to_consume=body.points_to_consume, + metadata=body.metadata, + ) + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def get_payment_intent(request: Request, intent_id: str) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + intent = legacy_routes.PAYMENT_CHECKOUT.get_intent( + user_id=identity["user_id"], + intent_id=intent_id, + ) + return {"intent": intent.__dict__} + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def submit_payment_tx( + request: Request, + intent_id: str, + body: SubmitPaymentTxRequest, +) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + return legacy_routes.PAYMENT_CHECKOUT.submit_intent_tx( + user_id=identity["user_id"], + intent_id=intent_id, + tx_hash=body.tx_hash, + from_address=body.from_address, + ) + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def confirm_payment_tx( + request: Request, + intent_id: str, + body: ConfirmPaymentTxRequest, +) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + return legacy_routes.PAYMENT_CHECKOUT.confirm_intent_tx( + user_id=identity["user_id"], + intent_id=intent_id, + tx_hash=body.tx_hash, + ) + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) + + +def reconcile_latest_payment(request: Request) -> Dict[str, Any]: + identity = _require_payment_identity(request) + try: + return legacy_routes.PAYMENT_CHECKOUT.reconcile_latest_intent(identity["user_id"]) + except legacy_routes.PaymentCheckoutError as exc: + _raise_payment_error(exc) diff --git a/web/services/scan_api.py b/web/services/scan_api.py new file mode 100644 index 00000000..393df4a8 --- /dev/null +++ b/web/services/scan_api.py @@ -0,0 +1,111 @@ +"""Market scan API service functions.""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import HTTPException, Request +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import StreamingResponse + +import web.routes as legacy_routes + + +def _boolish(value: Any) -> bool: + return str(value or "false").lower() in {"1", "true", "yes", "on"} + + +async def _json_body_or_empty(request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Invalid JSON body") + return body + + +def _extract_required_city(body: Dict[str, Any]) -> str: + city = str(body.get("city") or "").strip() + if not city: + raise HTTPException(status_code=400, detail="city is required") + return city + + +async def get_scan_terminal_payload( + request: Request, + *, + scan_mode: str = "tradable", + min_price: float = 0.05, + max_price: float = 0.95, + min_edge_pct: float = 2.0, + min_liquidity: float = 500.0, + high_liquidity_only: bool = False, + market_type: str = "maxtemp", + time_range: str = "today", + limit: int = 25, + force_refresh: bool = False, +) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + filters = { + "scan_mode": scan_mode, + "min_price": min_price, + "max_price": max_price, + "min_edge_pct": min_edge_pct, + "min_liquidity": min_liquidity, + "high_liquidity_only": high_liquidity_only, + "market_type": market_type, + "time_range": time_range, + "limit": limit, + } + return await run_in_threadpool( + legacy_routes.build_scan_terminal_payload, + filters, + force_refresh=force_refresh, + ) + + +async def get_scan_terminal_ai_payload(request: Request) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + body = await _json_body_or_empty(request) + filters = body.get("filters") if isinstance(body.get("filters"), dict) else {} + snapshot_id = str(body.get("snapshot_id") or "").strip() or None + return await run_in_threadpool( + legacy_routes.build_scan_terminal_ai_payload, + filters, + snapshot_id=snapshot_id, + ) + + +async def get_scan_city_ai_forecast_payload(request: Request) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + body = await _json_body_or_empty(request) + city = _extract_required_city(body) + force_refresh = _boolish(body.get("force_refresh")) + locale = str(body.get("locale") or "zh-CN").strip() + return await run_in_threadpool( + legacy_routes.build_scan_city_ai_forecast_payload, + city, + force_refresh=force_refresh, + locale=locale, + ) + + +async def get_scan_city_ai_stream_response(request: Request) -> StreamingResponse: + legacy_routes._assert_entitlement(request) + body = await _json_body_or_empty(request) + city = _extract_required_city(body) + force_refresh = _boolish(body.get("force_refresh")) + locale = str(body.get("locale") or "zh-CN").strip() + return StreamingResponse( + legacy_routes.stream_scan_city_ai_forecast_payload( + city, + force_refresh=force_refresh, + locale=locale, + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + }, + ) diff --git a/web/services/system_api.py b/web/services/system_api.py new file mode 100644 index 00000000..bc34fc66 --- /dev/null +++ b/web/services/system_api.py @@ -0,0 +1,169 @@ +"""System and observability API service functions.""" + +from __future__ import annotations + +import time +from typing import Any, Dict, Optional + +from fastapi import BackgroundTasks, HTTPException, Request +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import PlainTextResponse +from loguru import logger + +from src.utils.metrics import export_prometheus_metrics +from web.core import build_health_payload, build_system_status_payload +import web.routes as legacy_routes + + +def get_health_payload() -> Dict[str, Any]: + payload = build_health_payload() + if payload.get("status") != "ok": + raise HTTPException(status_code=503, detail=payload) + return payload + + +async def get_system_status_payload() -> Dict[str, Any]: + return await run_in_threadpool(build_system_status_payload) + + +def run_system_prewarm( + request: Request, + *, + cities: Optional[str] = None, + force_refresh: bool = False, + include_detail: bool = False, + include_market: bool = False, +) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + selected = legacy_routes._normalize_city_list(cities) + if not selected: + raise HTTPException(status_code=400, detail="No valid cities to prewarm") + + started = time.perf_counter() + warmed: list[dict[str, object]] = [] + failed: list[dict[str, object]] = [] + summary_ok = 0 + detail_ok = 0 + market_ok = 0 + + for city in selected: + city_started = time.perf_counter() + try: + legacy_routes._refresh_city_summary_cache(city, force_refresh=force_refresh) + entry: dict[str, object] = { + "city": city, + "summary": True, + "duration_ms": round((time.perf_counter() - city_started) * 1000.0, 1), + } + summary_ok += 1 + if include_detail: + legacy_routes._refresh_city_panel_cache(city, force_refresh=force_refresh) + entry["detail"] = True + detail_ok += 1 + if include_market: + entry["market"] = False + warmed.append(entry) + except Exception as exc: + failed.append( + { + "city": city, + "error": str(exc), + "duration_ms": round((time.perf_counter() - city_started) * 1000.0, 1), + } + ) + + total_ms = round((time.perf_counter() - started) * 1000.0, 1) + logger.info( + "system prewarm finished count={} failed={} force_refresh={} include_detail={} include_market={} duration_ms={}", + len(warmed), + len(failed), + force_refresh, + include_detail, + include_market, + total_ms, + ) + return { + "ok": len(failed) == 0, + "cities": selected, + "warmed": warmed, + "failed": failed, + "summary_ok": summary_ok, + "panel_ok": detail_ok, + "detail_ok": detail_ok, + "market_ok": market_ok, + "failed_count": len(failed), + "duration_ms": total_ms, + } + + +def get_system_cache_status(request: Request, cities: Optional[str] = None) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + selected = legacy_routes._normalize_city_list(cities) + if not selected: + selected = list(legacy_routes.DEFAULT_PREWARM_CITIES) + kinds = { + "summary": legacy_routes.CITY_SUMMARY_CACHE_TTL_SEC, + "panel": legacy_routes.CITY_PANEL_CACHE_TTL_SEC, + "nearby": legacy_routes.CITY_NEARBY_CACHE_TTL_SEC, + "market": legacy_routes.CITY_MARKET_CACHE_TTL_SEC, + "history_preview": legacy_routes.CITY_HISTORY_PREVIEW_CACHE_TTL_SEC, + } + items = [] + for city in selected: + row = {"city": city} + for kind, ttl_sec in kinds.items(): + entry = legacy_routes._CACHE_DB.get_city_cache(kind, city) + row[kind] = { + "exists": bool(entry), + "fresh": legacy_routes._city_cache_is_fresh(entry, ttl_sec), + "updated_at": entry.get("updated_at") if entry else None, + "age_sec": round(max(0.0, time.time() - float(entry.get("updated_at_ts") or 0.0)), 1) + if entry + else None, + "ttl_sec": ttl_sec, + } + items.append(row) + return {"cities": items} + + +def run_system_priority_warm( + request: Request, + background_tasks: BackgroundTasks, + timezone: Optional[str] = None, +) -> Dict[str, Any]: + legacy_routes._assert_entitlement(request) + batches = legacy_routes._select_priority_city_batches(timezone) + primary = list(batches.get("primary") or []) + secondary = list(batches.get("secondary") or []) + + def _runner() -> None: + for city in primary: + try: + legacy_routes._refresh_city_summary_cache(city, force_refresh=False) + legacy_routes._refresh_city_panel_cache(city, force_refresh=False) + legacy_routes._refresh_city_nearby_cache(city, force_refresh=False) + legacy_routes._refresh_city_market_cache(city, force_refresh=False) + except Exception as exc: + logger.warning("priority warm primary failed city={} timezone={}: {}", city, timezone, exc) + for city in secondary: + try: + legacy_routes._refresh_city_summary_cache(city, force_refresh=False) + legacy_routes._refresh_city_panel_cache(city, force_refresh=False) + except Exception as exc: + logger.warning("priority warm secondary failed city={} timezone={}: {}", city, timezone, exc) + + background_tasks.add_task(_runner) + return { + "ok": True, + "region": batches.get("region"), + "timezone": batches.get("timezone"), + "primary": primary, + "secondary": secondary, + } + + +def get_prometheus_metrics_response() -> PlainTextResponse: + return PlainTextResponse( + export_prometheus_metrics(), + media_type="text/plain; version=0.0.4; charset=utf-8", + )