架构重构:拆分 core/ops/DBManager,统一 SQLite 锁,DEB 改进,新增注意力模型
- web/core.py 858→236行,拆出 schemas/middleware/auth/diagnostics - ops_api.py 2876→4个 domain 模块 (users/payments/health/config) - DBManager Supabase HTTP 调用提取到 SupabaseAdminClient - 新增 LockedSQLiteConnection 统一多进程读写锁 - 新增 WeatherCacheManager 替代 12 个独立缓存字典 - METAR 缓存迁移至统一缓存管理器 - analysis_service 提取 _build_intraday_meteorology 到独立模块 - DEB 改进:偏差惩罚、分歧回退、自适应 lookback (MAE ↓12.6%) - 新增 PyTorch 注意力模型 deb_attention.py (数据积累后启用) - 新增 torch 到 requirements.lock
This commit is contained in:
+97
-718
@@ -1,38 +1,28 @@
|
||||
"""
|
||||
PolyWeather Web Core Context
|
||||
|
||||
Holds module-level singletons (app, config, weather collector, DB manager, cache)
|
||||
and re-exports symbols from domain-specific modules for backward compatibility.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
from src.utils.config_loader import load_config
|
||||
from src.utils.config_validation import validate_runtime_env
|
||||
from src.data_collection.weather_sources import WeatherDataCollector
|
||||
from src.data_collection.country_networks import provider_coverage_summary
|
||||
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401
|
||||
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token
|
||||
from src.utils.metrics import (
|
||||
build_metrics_summary,
|
||||
counter_inc,
|
||||
gauge_set,
|
||||
histogram_observe,
|
||||
)
|
||||
from src.database.db_manager import DBManager
|
||||
from src.database.runtime_state import get_state_storage_mode
|
||||
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError # noqa: F401
|
||||
from src.data_collection.city_registry import CITY_REGISTRY
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
app = FastAPI(title="PolyWeather Map", version="1.0")
|
||||
|
||||
_cors_origins = os.getenv(
|
||||
@@ -47,6 +37,9 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core singletons (must remain module-level for backward compat)
|
||||
# ---------------------------------------------------------------------------
|
||||
_config = load_config()
|
||||
_config_validation = validate_runtime_env("web")
|
||||
for _warning in _config_validation.warnings:
|
||||
@@ -79,6 +72,9 @@ SETTLEMENT_SOURCE_LABELS: Dict[str, str] = {
|
||||
"wunderground": "Wunderground",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LRUDict — simple size-bounded cache
|
||||
# ---------------------------------------------------------------------------
|
||||
class LRUDict:
|
||||
"""Size-bounded ordered dict that evicts oldest entries on overflow."""
|
||||
|
||||
@@ -111,405 +107,112 @@ CACHE_TTL_ANKARA = 60
|
||||
CACHE_TTL_KOREAN_AMOS = 60
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware — imported from web.middleware.http
|
||||
# ---------------------------------------------------------------------------
|
||||
from web.middleware.http import metrics_middleware as _metrics_middleware
|
||||
from web.middleware.http import etag_middleware as _etag_middleware
|
||||
|
||||
@app.middleware("http")
|
||||
async def _metrics_middleware(request: Request, call_next):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = (time.perf_counter() - started) * 1000.0
|
||||
counter_inc(
|
||||
"polyweather_http_requests_total",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status="500",
|
||||
)
|
||||
histogram_observe(
|
||||
"polyweather_http_request_duration_ms",
|
||||
duration_ms,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status="500",
|
||||
)
|
||||
raise
|
||||
app.middleware("http")(_metrics_middleware)
|
||||
app.middleware("http")(_etag_middleware)
|
||||
|
||||
duration_ms = (time.perf_counter() - started) * 1000.0
|
||||
status_code = str(response.status_code)
|
||||
counter_inc(
|
||||
"polyweather_http_requests_total",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=status_code,
|
||||
)
|
||||
histogram_observe(
|
||||
"polyweather_http_request_duration_ms",
|
||||
duration_ms,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status=status_code,
|
||||
)
|
||||
return response
|
||||
# Re-export middleware for external consumers
|
||||
metrics_middleware = _metrics_middleware
|
||||
etag_middleware = _etag_middleware
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas — re-exported from web.schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
from web.schemas.auth import ( # noqa: E402, F401
|
||||
AnalyticsEventRequest,
|
||||
FeedbackRewardRequest,
|
||||
GrantPointsRequest,
|
||||
ReferralApplyRequest,
|
||||
TelegramBindTokenRequest,
|
||||
TelegramLoginRequest,
|
||||
UserFeedbackRequest,
|
||||
)
|
||||
from web.schemas.payments import ( # noqa: E402, F401
|
||||
ConfirmPaymentTxRequest,
|
||||
CreatePaymentIntentRequest,
|
||||
SubmitPaymentTxRequest,
|
||||
ValidatePaymentTxRequest,
|
||||
WalletBindRequest,
|
||||
WalletChallengeRequest,
|
||||
WalletUnbindRequest,
|
||||
WalletVerifyRequest,
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _etag_middleware(request: Request, call_next):
|
||||
"""Add ETag to GET /api/* responses; return 304 on If-None-Match hit."""
|
||||
response = await call_next(request)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth guards — imported from web.auth.guards, wrappers for account_db
|
||||
# ---------------------------------------------------------------------------
|
||||
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT # noqa: E402
|
||||
|
||||
if request.method != "GET" or response.status_code != 200:
|
||||
return response
|
||||
import web.auth.guards as _auth_guards # noqa: E402
|
||||
|
||||
path = request.url.path
|
||||
if not path.startswith("/api/") or path.endswith("/stream"):
|
||||
return response
|
||||
|
||||
body = getattr(response, "body", None) or b""
|
||||
if not body:
|
||||
return response
|
||||
|
||||
try:
|
||||
import hashlib
|
||||
|
||||
etag = hashlib.md5(body).hexdigest()
|
||||
except Exception:
|
||||
return response
|
||||
|
||||
etag_value = f'"{etag}"'
|
||||
if_none_match = request.headers.get("If-None-Match", "")
|
||||
if if_none_match == etag_value:
|
||||
from fastapi.responses import Response
|
||||
|
||||
return Response(status_code=304, headers={"ETag": etag_value})
|
||||
|
||||
response.headers["ETag"] = etag_value
|
||||
response.headers["Cache-Control"] = "private, max-age=30"
|
||||
return response
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
_ENTITLEMENT_GUARD_ENABLED = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False)
|
||||
_ENTITLEMENT_HEADER = "x-polyweather-entitlement"
|
||||
_ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip()
|
||||
_FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id"
|
||||
_FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email"
|
||||
_SUPABASE_AUTH_REQUIRED = _env_bool(
|
||||
# Auth config flags — defined directly here so tests can monkeypatch web.core
|
||||
_SUPABASE_AUTH_REQUIRED = _auth_guards._env_bool(
|
||||
"POLYWEATHER_AUTH_REQUIRED",
|
||||
SUPABASE_ENTITLEMENT.enabled,
|
||||
)
|
||||
_OPS_ADMIN_EMAILS = {
|
||||
item.strip().lower()
|
||||
for item in str(os.getenv("POLYWEATHER_OPS_ADMIN_EMAILS") or "").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
_ENTITLEMENT_GUARD_ENABLED = _auth_guards._env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False)
|
||||
_ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def _legacy_service_token_valid(request: Request) -> bool:
|
||||
token = request.headers.get(_ENTITLEMENT_HEADER)
|
||||
if not token:
|
||||
token = extract_bearer_token(request.headers.get("authorization"))
|
||||
return bool(_ENTITLEMENT_TOKEN and token == _ENTITLEMENT_TOKEN)
|
||||
def _legacy_service_token_valid(request):
|
||||
return _auth_guards._legacy_service_token_valid(request)
|
||||
|
||||
|
||||
def _bind_forwarded_supabase_identity(request: Request) -> bool:
|
||||
if not _legacy_service_token_valid(request):
|
||||
return False
|
||||
forwarded_user_id = str(
|
||||
request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or ""
|
||||
).strip()
|
||||
if not forwarded_user_id:
|
||||
return False
|
||||
request.state.auth_user_id = forwarded_user_id
|
||||
request.state.auth_email = str(
|
||||
request.headers.get(_FORWARDED_SUPABASE_EMAIL_HEADER) or ""
|
||||
).strip()
|
||||
return True
|
||||
def _bind_forwarded_supabase_identity(request):
|
||||
return _auth_guards._bind_forwarded_supabase_identity(request)
|
||||
|
||||
|
||||
def _bind_optional_supabase_identity(request: Request) -> None:
|
||||
if _bind_forwarded_supabase_identity(request):
|
||||
return
|
||||
if not SUPABASE_ENTITLEMENT.configured:
|
||||
return
|
||||
access_token = extract_bearer_token(request.headers.get("authorization"))
|
||||
if not access_token:
|
||||
return
|
||||
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
|
||||
if not identity:
|
||||
return
|
||||
request.state.auth_user_id = identity.user_id
|
||||
request.state.auth_email = identity.email
|
||||
request.state.auth_points = identity.points
|
||||
request.state.auth_created_at = identity.created_at
|
||||
from src.utils.online_tracker import record_activity
|
||||
record_activity(identity.user_id)
|
||||
def _bind_optional_supabase_identity(request):
|
||||
return _auth_guards._bind_optional_supabase_identity(request)
|
||||
|
||||
|
||||
def _resolve_auth_points(request: Request) -> int:
|
||||
raw_points = getattr(request.state, "auth_points", 0)
|
||||
try:
|
||||
points = max(0, int(raw_points or 0))
|
||||
except Exception:
|
||||
points = 0
|
||||
|
||||
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
||||
|
||||
if user_id:
|
||||
try:
|
||||
db_points = _account_db.get_points_by_supabase_user_id(user_id)
|
||||
if db_points > points:
|
||||
request.state.auth_points = db_points
|
||||
points = db_points
|
||||
except Exception as exc:
|
||||
logger.warning(f"auth points fallback failed user_id={user_id}: {exc}")
|
||||
|
||||
if points <= 0:
|
||||
email = str(getattr(request.state, "auth_email", "") or "").strip().lower()
|
||||
if email:
|
||||
try:
|
||||
email_points = _account_db.get_points_by_supabase_email(email)
|
||||
if email_points > points:
|
||||
request.state.auth_points = email_points
|
||||
points = email_points
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"auth points email fallback failed email={email}: {exc}"
|
||||
)
|
||||
|
||||
return points
|
||||
def _resolve_auth_points(request):
|
||||
return _auth_guards._resolve_auth_points(request, account_db=_account_db)
|
||||
|
||||
|
||||
def _resolve_weekly_profile(request: Request) -> Dict[str, Any]:
|
||||
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
||||
if not user_id:
|
||||
return {"weekly_points": 0, "weekly_rank": None}
|
||||
try:
|
||||
profile = _account_db.get_weekly_profile_by_supabase_user_id(user_id)
|
||||
return {
|
||||
"weekly_points": int(profile.get("weekly_points") or 0),
|
||||
"weekly_rank": profile.get("weekly_rank"),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning(f"auth weekly profile fallback failed user_id={user_id}: {exc}")
|
||||
return {"weekly_points": 0, "weekly_rank": None}
|
||||
def _resolve_weekly_profile(request):
|
||||
return _auth_guards._resolve_weekly_profile(request, account_db=_account_db)
|
||||
|
||||
|
||||
def _assert_entitlement(request: Request) -> None:
|
||||
if SUPABASE_ENTITLEMENT.enabled:
|
||||
if _legacy_service_token_valid(request):
|
||||
if _bind_forwarded_supabase_identity(request):
|
||||
return
|
||||
bearer_token = extract_bearer_token(request.headers.get("authorization"))
|
||||
if not bearer_token or bearer_token == _ENTITLEMENT_TOKEN:
|
||||
return
|
||||
if not _SUPABASE_AUTH_REQUIRED:
|
||||
_bind_optional_supabase_identity(request)
|
||||
return
|
||||
if not SUPABASE_ENTITLEMENT.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Supabase auth is enabled but SUPABASE_URL / SUPABASE_ANON_KEY is not configured",
|
||||
)
|
||||
|
||||
access_token = extract_bearer_token(request.headers.get("authorization"))
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
identity = SUPABASE_ENTITLEMENT.get_identity(access_token)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
skip_subscription_gate = bool(
|
||||
getattr(request.state, "skip_subscription_gate", False)
|
||||
)
|
||||
if (
|
||||
not skip_subscription_gate
|
||||
and not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Subscription required")
|
||||
|
||||
request.state.auth_user_id = identity.user_id
|
||||
request.state.auth_email = identity.email
|
||||
request.state.auth_points = identity.points
|
||||
request.state.auth_created_at = identity.created_at
|
||||
from src.utils.online_tracker import record_activity
|
||||
record_activity(identity.user_id)
|
||||
return
|
||||
|
||||
if not _ENTITLEMENT_GUARD_ENABLED:
|
||||
return
|
||||
|
||||
if not _ENTITLEMENT_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Entitlement guard is enabled but backend token is not configured",
|
||||
)
|
||||
|
||||
if not _legacy_service_token_valid(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
def _assert_entitlement(request):
|
||||
return _auth_guards._assert_entitlement(request)
|
||||
|
||||
|
||||
def _require_supabase_identity(request: Request) -> Dict[str, str]:
|
||||
if not SUPABASE_ENTITLEMENT.enabled:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="payment requires POLYWEATHER_AUTH_ENABLED=true"
|
||||
)
|
||||
if not SUPABASE_ENTITLEMENT.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="payment requires SUPABASE_URL and SUPABASE_ANON_KEY",
|
||||
)
|
||||
def _require_supabase_identity(request):
|
||||
return _auth_guards._require_supabase_identity(request)
|
||||
|
||||
state_user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
||||
if state_user_id:
|
||||
state_email = str(getattr(request.state, "auth_email", "") or "").strip()
|
||||
return {"user_id": state_user_id, "email": state_email}
|
||||
|
||||
token = extract_bearer_token(request.headers.get("authorization"))
|
||||
if token:
|
||||
identity = SUPABASE_ENTITLEMENT.get_identity(token)
|
||||
if identity:
|
||||
return {"user_id": identity.user_id, "email": identity.email}
|
||||
def _require_ops_admin(request):
|
||||
return _auth_guards._require_ops_admin(request)
|
||||
|
||||
legacy_ok = _legacy_service_token_valid(request)
|
||||
if legacy_ok:
|
||||
forwarded_user_id = str(
|
||||
request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or ""
|
||||
).strip()
|
||||
if forwarded_user_id:
|
||||
forwarded_email = str(
|
||||
request.headers.get(_FORWARDED_SUPABASE_EMAIL_HEADER) or ""
|
||||
).strip()
|
||||
return {"user_id": forwarded_user_id, "email": forwarded_email}
|
||||
# Entitlement token is valid but forwarded headers are missing.
|
||||
# Return a placeholder identity — callers (e.g. _require_ops_admin)
|
||||
# can decide whether to accept it.
|
||||
return {"user_id": "entitlement", "email": ""}
|
||||
|
||||
logger.warning(
|
||||
"payment auth identity missing state_user={} auth_bearer={} legacy_ok={} forwarded_user={}".format(
|
||||
bool(state_user_id),
|
||||
bool(token),
|
||||
bool(legacy_ok),
|
||||
bool(
|
||||
str(
|
||||
request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or ""
|
||||
).strip()
|
||||
),
|
||||
)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostics — re-exported from web.diagnostics.health
|
||||
# ---------------------------------------------------------------------------
|
||||
from web.diagnostics.health import ( # noqa: E402
|
||||
build_health_payload as _build_health_payload_raw,
|
||||
build_system_status_payload as _build_system_status_payload_raw,
|
||||
)
|
||||
|
||||
|
||||
def build_health_payload():
|
||||
return _build_health_payload_raw(_account_db, len(CITIES))
|
||||
|
||||
|
||||
def build_system_status_payload():
|
||||
return _build_system_status_payload_raw(
|
||||
_account_db, _config, _weather, _cache, len(_cache), len(CITIES), CITY_REGISTRY
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
def _require_ops_admin(request: Request) -> Dict[str, str]:
|
||||
is_entitlement = _legacy_service_token_valid(request)
|
||||
identity = _require_supabase_identity(request)
|
||||
if not _OPS_ADMIN_EMAILS:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="ops admin is not configured; set POLYWEATHER_OPS_ADMIN_EMAILS",
|
||||
)
|
||||
email = str(identity.get("email") or "").strip().lower()
|
||||
if email and email in _OPS_ADMIN_EMAILS:
|
||||
return identity
|
||||
# If identity lacks an email (e.g. pure entitlement-token auth with
|
||||
# missing forwarded headers), loosen the requirement: entitlement token
|
||||
# alone is sufficient admin proof when admin list is configured.
|
||||
if is_entitlement:
|
||||
granted = {
|
||||
"user_id": "admin:entitlement",
|
||||
"email": next(iter(_OPS_ADMIN_EMAILS)),
|
||||
}
|
||||
return granted
|
||||
raise HTTPException(status_code=403, detail="ops admin required")
|
||||
|
||||
|
||||
class WalletChallengeRequest(BaseModel):
|
||||
address: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class WalletVerifyRequest(BaseModel):
|
||||
address: str = Field(..., min_length=8)
|
||||
nonce: str = Field(..., min_length=6)
|
||||
signature: str = Field(..., min_length=20)
|
||||
|
||||
|
||||
class WalletUnbindRequest(BaseModel):
|
||||
address: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class CreatePaymentIntentRequest(BaseModel):
|
||||
plan_code: str = Field(default="pro_monthly", min_length=2)
|
||||
payment_mode: str = Field(default="strict")
|
||||
allowed_wallet: Optional[str] = None
|
||||
token_address: Optional[str] = None
|
||||
chain_id: Optional[int] = None
|
||||
use_points: bool = False
|
||||
points_to_consume: Optional[int] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SubmitPaymentTxRequest(BaseModel):
|
||||
tx_hash: str = Field(..., min_length=10)
|
||||
from_address: Optional[str] = None
|
||||
|
||||
|
||||
class ValidatePaymentTxRequest(BaseModel):
|
||||
tx_hash: str = Field(..., min_length=10)
|
||||
|
||||
|
||||
class ConfirmPaymentTxRequest(BaseModel):
|
||||
tx_hash: Optional[str] = None
|
||||
|
||||
|
||||
class ReferralApplyRequest(BaseModel):
|
||||
code: str = Field(..., min_length=3, max_length=32)
|
||||
|
||||
|
||||
class TelegramLoginRequest(BaseModel):
|
||||
id: int
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
photo_url: Optional[str] = None
|
||||
auth_date: int
|
||||
hash: str = Field(..., min_length=10)
|
||||
|
||||
|
||||
class TelegramBindTokenRequest(BaseModel):
|
||||
token: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class AnalyticsEventRequest(BaseModel):
|
||||
event_type: str = Field(..., min_length=3, max_length=64)
|
||||
client_id: Optional[str] = Field(default=None, max_length=128)
|
||||
session_id: Optional[str] = Field(default=None, max_length=128)
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserFeedbackRequest(BaseModel):
|
||||
category: str = Field(default="bug", min_length=2, max_length=40)
|
||||
message: str = Field(..., min_length=3, max_length=5000)
|
||||
source: str = Field(default="terminal", max_length=40)
|
||||
contact: Optional[str] = Field(default=None, max_length=180)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GrantPointsRequest(BaseModel):
|
||||
email: str = Field(..., min_length=3)
|
||||
points: int = Field(..., gt=0, le=100000)
|
||||
|
||||
|
||||
class FeedbackRewardRequest(BaseModel):
|
||||
points: int = Field(..., gt=0, le=100000)
|
||||
reason: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _sf(v) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
@@ -523,335 +226,11 @@ def _is_excluded_model_name(model_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _sqlite_health() -> Dict[str, Any]:
|
||||
try:
|
||||
with sqlite3.connect(_account_db.db_path, timeout=0.05) as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
return {"ok": True, "db_path": _account_db.db_path}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "db_path": _account_db.db_path, "error": str(exc)}
|
||||
|
||||
|
||||
def _cache_summary() -> Dict[str, Any]:
|
||||
from web.analysis_service import get_analysis_cache_stats
|
||||
|
||||
open_meteo_forecast_entries = len(getattr(_weather, "_open_meteo_cache", {}) or {})
|
||||
open_meteo_ensemble_entries = len(getattr(_weather, "_ensemble_cache", {}) or {})
|
||||
open_meteo_multi_model_entries = len(
|
||||
getattr(_weather, "_multi_model_cache", {}) or {}
|
||||
)
|
||||
metar_entries = len(getattr(_weather, "_metar_cache", {}) or {})
|
||||
taf_entries = len(getattr(_weather, "_taf_cache", {}) or {})
|
||||
settlement_entries = len(getattr(_weather, "_settlement_cache", {}) or {})
|
||||
|
||||
gauge_set("polyweather_api_cache_entries", len(_cache))
|
||||
gauge_set(
|
||||
"polyweather_open_meteo_forecast_cache_entries", open_meteo_forecast_entries
|
||||
)
|
||||
gauge_set(
|
||||
"polyweather_open_meteo_ensemble_cache_entries", open_meteo_ensemble_entries
|
||||
)
|
||||
gauge_set(
|
||||
"polyweather_open_meteo_multi_model_cache_entries",
|
||||
open_meteo_multi_model_entries,
|
||||
)
|
||||
gauge_set("polyweather_metar_cache_entries", metar_entries)
|
||||
gauge_set("polyweather_taf_cache_entries", taf_entries)
|
||||
gauge_set("polyweather_settlement_cache_entries", settlement_entries)
|
||||
return {
|
||||
"api_cache_entries": len(_cache),
|
||||
"open_meteo_forecast_entries": open_meteo_forecast_entries,
|
||||
"open_meteo_ensemble_entries": open_meteo_ensemble_entries,
|
||||
"open_meteo_multi_model_entries": open_meteo_multi_model_entries,
|
||||
"metar_entries": metar_entries,
|
||||
"taf_entries": taf_entries,
|
||||
"settlement_entries": settlement_entries,
|
||||
"analysis": get_analysis_cache_stats(),
|
||||
}
|
||||
|
||||
|
||||
def _feature_flags_summary() -> Dict[str, Any]:
|
||||
return {
|
||||
"auth_enabled": bool(SUPABASE_ENTITLEMENT.enabled),
|
||||
"auth_required": bool(_SUPABASE_AUTH_REQUIRED),
|
||||
"entitlement_guard_enabled": bool(_ENTITLEMENT_GUARD_ENABLED),
|
||||
"payment_enabled": bool(getattr(PAYMENT_CHECKOUT, "enabled", False)),
|
||||
"state_storage_mode": get_state_storage_mode(),
|
||||
}
|
||||
|
||||
|
||||
def _integration_summary() -> Dict[str, Any]:
|
||||
weather_cfg = _config.get("weather", {}) if isinstance(_config, dict) else {}
|
||||
return {
|
||||
"supabase_configured": bool(SUPABASE_ENTITLEMENT.configured),
|
||||
"telegram_bot_configured": bool(
|
||||
(_config.get("telegram", {}) or {}).get("bot_token")
|
||||
),
|
||||
"walletconnect_configured": bool(
|
||||
os.getenv("NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID")
|
||||
),
|
||||
"weather_sources": {
|
||||
"openweather": bool(weather_cfg.get("openweather_api_key")),
|
||||
"wunderground": bool(weather_cfg.get("wunderground_api_key")),
|
||||
"visualcrossing": bool(weather_cfg.get("visualcrossing_api_key")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _probability_summary() -> Dict[str, Any]:
|
||||
return {
|
||||
"engine_mode": "legacy",
|
||||
}
|
||||
|
||||
|
||||
def _read_json_file(path: str) -> Optional[Dict[str, Any]]:
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _table_date_summary(conn: sqlite3.Connection, table_name: str) -> Dict[str, Any]:
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS row_count,
|
||||
COUNT(DISTINCT city) AS cities_count,
|
||||
MIN(target_date) AS min_date,
|
||||
MAX(target_date) AS max_date
|
||||
FROM {table_name}
|
||||
"""
|
||||
).fetchone()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc), "row_count": 0, "cities_count": 0}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"row_count": int(row["row_count"] or 0),
|
||||
"cities_count": int(row["cities_count"] or 0),
|
||||
"min_date": row["min_date"],
|
||||
"max_date": row["max_date"],
|
||||
}
|
||||
|
||||
|
||||
def _truth_source_counts(conn: sqlite3.Connection) -> Dict[str, int]:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(NULLIF(TRIM(settlement_source), ''), 'unknown') AS settlement_source,
|
||||
COUNT(*) AS row_count
|
||||
FROM truth_records_store
|
||||
GROUP BY COALESCE(NULLIF(TRIM(settlement_source), ''), 'unknown')
|
||||
ORDER BY row_count DESC, settlement_source ASC
|
||||
"""
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return {}
|
||||
return {str(row["settlement_source"]): int(row["row_count"] or 0) for row in rows}
|
||||
|
||||
|
||||
def _truth_revisions_summary(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS row_count,
|
||||
MAX(updated_at) AS last_updated_at
|
||||
FROM truth_revisions_store
|
||||
"""
|
||||
).fetchone()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc), "row_count": 0}
|
||||
return {
|
||||
"ok": True,
|
||||
"row_count": int(row["row_count"] or 0),
|
||||
"last_updated_at": row["last_updated_at"],
|
||||
}
|
||||
|
||||
|
||||
def _city_coverage_summary(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
truth_rows = conn.execute(
|
||||
"""
|
||||
SELECT city, COUNT(*) AS row_count, MIN(target_date) AS min_date, MAX(target_date) AS max_date
|
||||
FROM truth_records_store
|
||||
GROUP BY city
|
||||
"""
|
||||
).fetchall()
|
||||
feature_rows = conn.execute(
|
||||
"""
|
||||
SELECT city, COUNT(*) AS row_count, MIN(target_date) AS min_date, MAX(target_date) AS max_date
|
||||
FROM training_feature_records_store
|
||||
GROUP BY city
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
truth_index = {
|
||||
str(row["city"]): {
|
||||
"truth_rows": int(row["row_count"] or 0),
|
||||
"truth_min_date": row["min_date"],
|
||||
"truth_max_date": row["max_date"],
|
||||
}
|
||||
for row in truth_rows
|
||||
}
|
||||
feature_index = {
|
||||
str(row["city"]): {
|
||||
"feature_rows": int(row["row_count"] or 0),
|
||||
"feature_min_date": row["min_date"],
|
||||
"feature_max_date": row["max_date"],
|
||||
}
|
||||
for row in feature_rows
|
||||
}
|
||||
|
||||
entries = []
|
||||
for city, meta in CITY_REGISTRY.items():
|
||||
truth_payload = truth_index.get(city, {})
|
||||
feature_payload = feature_index.get(city, {})
|
||||
entries.append(
|
||||
{
|
||||
"city": city,
|
||||
"name": str(meta.get("name") or city),
|
||||
"settlement_source": str(meta.get("settlement_source") or "metar"),
|
||||
"settlement_station_code": str(
|
||||
meta.get("settlement_station_code") or meta.get("icao") or ""
|
||||
),
|
||||
"truth_rows": int(truth_payload.get("truth_rows") or 0),
|
||||
"feature_rows": int(feature_payload.get("feature_rows") or 0),
|
||||
"truth_min_date": truth_payload.get("truth_min_date"),
|
||||
"truth_max_date": truth_payload.get("truth_max_date"),
|
||||
"feature_min_date": feature_payload.get("feature_min_date"),
|
||||
"feature_max_date": feature_payload.get("feature_max_date"),
|
||||
}
|
||||
)
|
||||
|
||||
highlighted = [
|
||||
entry for entry in entries if entry["city"] in {"taipei", "shenzhen"}
|
||||
]
|
||||
gaps = sorted(
|
||||
entries,
|
||||
key=lambda entry: (
|
||||
entry["feature_rows"] > 0,
|
||||
entry["truth_rows"] > 0,
|
||||
entry["truth_rows"],
|
||||
entry["feature_rows"],
|
||||
entry["city"],
|
||||
),
|
||||
)[:10]
|
||||
return {
|
||||
"total_cities": len(entries),
|
||||
"with_truth_rows": sum(1 for entry in entries if entry["truth_rows"] > 0),
|
||||
"with_feature_rows": sum(1 for entry in entries if entry["feature_rows"] > 0),
|
||||
"entries": entries,
|
||||
"highlighted": highlighted,
|
||||
"top_gaps": gaps,
|
||||
}
|
||||
|
||||
|
||||
def _model_city_coverage_summary(
|
||||
city_entries: Any,
|
||||
) -> Dict[str, Any]:
|
||||
rows = []
|
||||
for entry in city_entries or []:
|
||||
city = str(entry.get("city") or "").strip().lower()
|
||||
rows.append(
|
||||
{
|
||||
"city": city,
|
||||
"name": entry.get("name") or city,
|
||||
"settlement_source": entry.get("settlement_source"),
|
||||
"truth_rows": int(entry.get("truth_rows") or 0),
|
||||
"feature_rows": int(entry.get("feature_rows") or 0),
|
||||
}
|
||||
)
|
||||
|
||||
weakest = sorted(
|
||||
rows,
|
||||
key=lambda row: (
|
||||
row["truth_rows"] > 0,
|
||||
row["truth_rows"],
|
||||
row["city"],
|
||||
),
|
||||
)[:12]
|
||||
strongest = sorted(
|
||||
rows,
|
||||
key=lambda row: (
|
||||
-row["truth_rows"],
|
||||
row["city"],
|
||||
),
|
||||
)[:8]
|
||||
return {
|
||||
"weakest": weakest,
|
||||
"strongest": strongest,
|
||||
}
|
||||
|
||||
|
||||
def _training_data_summary() -> Dict[str, Any]:
|
||||
db_path = _account_db.db_path
|
||||
truth_records = {"ok": False, "row_count": 0, "cities_count": 0}
|
||||
truth_revisions = {"ok": False, "row_count": 0}
|
||||
training_features = {"ok": False, "row_count": 0, "cities_count": 0}
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
truth_records = _table_date_summary(conn, "truth_records_store")
|
||||
if truth_records.get("ok"):
|
||||
truth_records["source_counts"] = _truth_source_counts(conn)
|
||||
truth_revisions = _truth_revisions_summary(conn)
|
||||
training_features = _table_date_summary(
|
||||
conn, "training_feature_records_store"
|
||||
)
|
||||
city_coverage = _city_coverage_summary(conn)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"db_path": db_path,
|
||||
"db_ok": False,
|
||||
"error": str(exc),
|
||||
"truth_records": truth_records,
|
||||
"truth_revisions": truth_revisions,
|
||||
"training_features": training_features,
|
||||
"city_coverage": {},
|
||||
"model_city_coverage": {},
|
||||
}
|
||||
|
||||
return {
|
||||
"db_path": db_path,
|
||||
"db_ok": True,
|
||||
"truth_records": truth_records,
|
||||
"truth_revisions": truth_revisions,
|
||||
"training_features": training_features,
|
||||
"city_coverage": city_coverage,
|
||||
"model_city_coverage": _model_city_coverage_summary(
|
||||
city_coverage.get("entries") or [],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_health_payload() -> Dict[str, Any]:
|
||||
db = _sqlite_health()
|
||||
return {
|
||||
"status": "ok" if db.get("ok") else "degraded",
|
||||
"time_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"db": db,
|
||||
"state_storage_mode": get_state_storage_mode(),
|
||||
"cities_count": len(CITIES),
|
||||
}
|
||||
|
||||
|
||||
def build_system_status_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"status": build_health_payload()["status"],
|
||||
"time_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"state_storage_mode": get_state_storage_mode(),
|
||||
"db": _sqlite_health(),
|
||||
"features": _feature_flags_summary(),
|
||||
"integrations": _integration_summary(),
|
||||
"cache": _cache_summary(),
|
||||
"metrics": build_metrics_summary(),
|
||||
"probability": _probability_summary(),
|
||||
"training_data": _training_data_summary(),
|
||||
"station_networks": provider_coverage_summary(),
|
||||
"cities_count": len(CITIES),
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compatibility re-exports — symbols that were accessible via web.core
|
||||
# in the old monolithic module, re-exported for backward compatibility.
|
||||
# ---------------------------------------------------------------------------
|
||||
from src.data_collection.city_registry import CITY_REGISTRY # noqa: E402, F401
|
||||
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: E402, F401
|
||||
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError # noqa: E402, F401
|
||||
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT # noqa: E402, F401
|
||||
|
||||
Reference in New Issue
Block a user