diff --git a/.env.example b/.env.example index 651f28dc..a4c71a9f 100644 --- a/.env.example +++ b/.env.example @@ -134,12 +134,18 @@ POLYWEATHER_DEEPSEEK_API_KEY= POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40 -POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=32 -POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=120 +POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=45 +POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800 POLYWEATHER_SCAN_AI_MAX_ROWS=40 POLYWEATHER_SCAN_AI_MAX_TOKENS=3200 +POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=1200 POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000 POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid +POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800 +POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC=1800 +POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC=1800 +POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC=1800 +POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC=1800 # Weekly reward / leaderboard POLYWEATHER_WEEKLY_REWARD_ENABLED=true diff --git a/frontend/app/api/scan/terminal/ai-city/route.ts b/frontend/app/api/scan/terminal/ai-city/route.ts index 67e0130a..eaf07cf6 100644 --- a/frontend/app/api/scan/terminal/ai-city/route.ts +++ b/frontend/app/api/scan/terminal/ai-city/route.ts @@ -41,6 +41,7 @@ export async function POST(req: NextRequest) { console.info("[scan-ai-city] proxy request", { city: requestBody.city, force_refresh: requestBody.force_refresh === true, + locale: requestBody.locale, }); const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, { method: "POST", diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 4eb82040..e9ca80af 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -643,6 +643,7 @@ function AiPinnedCityCard({ body: JSON.stringify({ city: detailCityName, force_refresh: aiRefreshToken > 0, + locale, }), }) .then(async (response) => { @@ -664,7 +665,7 @@ function AiPinnedCityCard({ return () => { cancelled = true; }; - }, [aiForecastKey, aiRefreshToken, detailCityName]); + }, [aiForecastKey, aiRefreshToken, detailCityName, locale]); const aiCityForecast = aiForecast.payload?.city_forecast || null; const localizedFinalJudgment = diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index bc55413c..48b7a30d 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -116,7 +116,6 @@ function getInitialProAccessState(): ProAccessState { } const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1"; -const BACKGROUND_SUMMARY_REFRESH_MS = 30_000; const CITY_LOAD_RETRY_DELAYS_MS = [700, 1600]; type CityDetailDepth = "panel" | "market" | "nearby" | "full"; @@ -228,10 +227,6 @@ function detailSatisfiesDepth( return normalizeDetailDepth(detail) === "full"; } -function shouldCheckSparseCoverageForDepth(depth: CityDetailDepth) { - return depth === "panel" || depth === "market" || depth === "full"; -} - function hasMeaningfulModelMap( value: Record | undefined, ): value is Record { @@ -399,7 +394,6 @@ export function DashboardStoreProvider({ const modalOpenSeqRef = useRef(0); const hydratedSelectionRef = useRef(false); const hydratedProCacheRef = useRef(false); - const backgroundSummaryCheckAtRef = useRef>({}); const summaryInflightByCityRef = useRef>>( {}, ); @@ -474,32 +468,36 @@ export function DashboardStoreProvider({ dashboardClient.clearCityDetailCache(); }, [proAccess]); - const scheduleBackgroundDetailRefresh = ( + const ensureCityDetail = async ( cityName: string, - cached: CityDetail, - cachedMeta?: { cachedAt: number; revision: string }, + force = false, + depth: CityDetailDepth = "panel", ) => { - const nowTs = Date.now(); - const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0; - if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) { - return; + const cached = cityDetailsByName[cityName]; + const cachedMeta = cityDetailMetaByName[cityName]; + const marketTargetDate = + depth === "market" ? selectedForecastDate || cached?.local_date : null; + const hasRequestedDepth = detailSatisfiesDepth( + cached, + depth, + marketTargetDate, + ); + if ( + !force && + cached && + hasRequestedDepth && + dashboardClient.isCityDetailFresh(cachedMeta) + ) { + return cached; } - backgroundSummaryCheckAtRef.current[cityName] = nowTs; - - void dashboardClient - .getCitySummary(cityName) - .then(async (summary) => { - const revision = getCityRevision(summary); - if (!revision || revision === cachedMeta?.revision) { - return; - } + if (!force && cached && hasRequestedDepth) { + try { const latestDetail = await dashboardClient.getCityDetail(cityName, { - force: false, - depth: normalizeDetailDepth(cached), + force: true, + depth, }); const detail = latestDetail; - setCityDetailsByName((current) => ({ ...current, [cityName]: mergeCityDetail(current[cityName], detail), @@ -515,77 +513,7 @@ export function DashboardStoreProvider({ revision: getCityRevision(detail), }, })); - }) - .catch(() => {}); - }; - - const ensureCityDetail = async ( - cityName: string, - force = false, - depth: CityDetailDepth = "panel", - ) => { - const cached = cityDetailsByName[cityName]; - const cachedMeta = cityDetailMetaByName[cityName]; - const marketTargetDate = - depth === "market" ? selectedForecastDate || cached?.local_date : null; - const hasRequestedDepth = detailSatisfiesDepth( - cached, - depth, - marketTargetDate, - ); - const cachedIsSparse = - shouldCheckSparseCoverageForDepth(depth) && - (depth === "market" - ? hasSparseModelCoverage(cached, marketTargetDate) - : hasSparseDetailCoverage(cached, cached?.local_date)); - if ( - !force && - cached && - hasRequestedDepth && - !cachedIsSparse && - dashboardClient.isCityDetailFresh(cachedMeta) - ) { - scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta); - return cached; - } - - if (!force && cached && hasRequestedDepth) { - try { - const summary = await dashboardClient.getCitySummary(cityName); - const revision = getCityRevision(summary); - if (revision && revision === cachedMeta?.revision) { - if (cachedIsSparse) { - const latestDetail = await dashboardClient.getCityDetail(cityName, { - force: true, - depth, - }); - const detail = latestDetail; - setCityDetailsByName((current) => ({ - ...current, - [cityName]: mergeCityDetail(current[cityName], detail), - })); - setCitySummariesByName((current) => ({ - ...current, - [cityName]: toCitySummary(detail), - })); - setCityDetailMetaByName((current) => ({ - ...current, - [cityName]: { - cachedAt: Date.now(), - revision: getCityRevision(detail), - }, - })); - return detail; - } - setCityDetailMetaByName((current) => ({ - ...current, - [cityName]: { - cachedAt: Date.now(), - revision, - }, - })); - return cached; - } + return detail; } catch { return cached; } diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index d6781a12..dcc96aad 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -62,7 +62,7 @@ export type AssistantChatResponse = { }; const CACHE_KEY = "polyWeather_v1"; -const CACHE_TTL_MS = 5 * 60 * 1000; +const CACHE_TTL_MS = 30 * 60 * 1000; const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000; const pendingCityDetailRequests = new Map>(); const pendingHistoryRequests = new Map>(); @@ -244,16 +244,18 @@ function readLegacyCache(raw: string): CityCacheBundle { }; const details = parsed.data || {}; const cachedAt = parsed.timestamp || 0; - const meta = Object.fromEntries( - Object.entries(details).map(([cityName, detail]) => [ - cityName, - { - cachedAt, - revision: getCityRevision(detail), - }, - ]), - ); - return { details, meta }; + const freshDetails: Record = {}; + const meta: Record = {}; + Object.entries(details).forEach(([cityName, detail]) => { + const nextMeta = { + cachedAt, + revision: getCityRevision(detail), + }; + if (!isFresh(nextMeta)) return; + freshDetails[cityName] = detail; + meta[cityName] = nextMeta; + }); + return { details: freshDetails, meta }; } export const dashboardClient = { @@ -606,11 +608,13 @@ export const dashboardClient = { const meta: Record = {}; Object.entries(parsed.entries).forEach(([cityName, entry]) => { if (!entry?.detail) return; - details[cityName] = entry.detail; - meta[cityName] = { + const nextMeta = { cachedAt: entry.cachedAt || 0, revision: entry.revision || getCityRevision(entry.detail), }; + if (!isFresh(nextMeta)) return; + details[cityName] = entry.detail; + meta[cityName] = nextMeta; }); return { details, meta }; } diff --git a/web/routes.py b/web/routes.py index 9ef8766a..59c4345f 100644 --- a/web/routes.py +++ b/web/routes.py @@ -147,10 +147,10 @@ US_CORE_CITIES = [ "atlanta", "seattle", ] -CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "180"))) -CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "300"))) -CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "480"))) -CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900"))) +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")), @@ -969,7 +969,7 @@ async def city_detail( 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): - _schedule_cache_refresh(background_tasks, kind="panel", city=city) + 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": @@ -978,7 +978,7 @@ async def city_detail( 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): - _schedule_cache_refresh(background_tasks, kind="nearby", city=city) + 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": @@ -987,7 +987,7 @@ async def city_detail( 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): - _schedule_cache_refresh(background_tasks, kind="market", city=city) + 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) @@ -1007,7 +1007,7 @@ async def city_history( 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): - _schedule_cache_refresh(background_tasks, kind="history_preview", city=city) + 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) @@ -1610,7 +1610,7 @@ async def city_summary( 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): - _schedule_cache_refresh(background_tasks, kind="summary", city=city) + 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) @@ -1769,9 +1769,11 @@ async def scan_terminal_ai_city(request: Request): "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, ) diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 0bfa8a92..67590674 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -47,11 +47,11 @@ SCAN_AI_TIMEOUT_SEC = max( ) SCAN_CITY_AI_TIMEOUT_SEC = max( 20, - int(os.getenv("POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", "32")), + int(os.getenv("POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", "45")), ) SCAN_AI_CACHE_TTL_SEC = max( 30, - int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "120")), + int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "1800")), ) SCAN_AI_MAX_ROWS = max( 1, @@ -61,6 +61,10 @@ SCAN_AI_MAX_TOKENS = max( 600, int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "3200")), ) +SCAN_CITY_AI_MAX_TOKENS = max( + 500, + int(os.getenv("POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", "1200")), +) def _safe_float(value: Any) -> Optional[float]: @@ -79,6 +83,11 @@ def _safe_int(value: Any, default: int) -> int: return int(default) +def _normalize_locale(value: Any) -> str: + text = str(value or "").strip().lower() + return "en-US" if text.startswith("en") else "zh-CN" + + def _normalize_scan_terminal_filters( raw_filters: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: @@ -892,10 +901,14 @@ def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]: } -def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: +def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -> Dict[str, Any]: api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip() if not api_key: raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") + normalized_locale = _normalize_locale(locale) + primary_language = "English" if normalized_locale == "en-US" else "Simplified Chinese" + primary_suffix = "_en" if normalized_locale == "en-US" else "_zh" + secondary_suffix = "_zh" if normalized_locale == "en-US" else "_en" system_prompt = ( "你是 PolyWeather 的 Deepseek V4-Pro 城市最高温预测员。你必须直接阅读用户给出的城市 JSON," @@ -906,15 +919,21 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: "你可以基于城市、时间、季节、机场位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径," "但必须使用“可能”“倾向”“需要确认”等非绝对表达。" "如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。" + f"当前用户界面语言是 {normalized_locale},所有面向用户的主要自然语言字段必须使用 {primary_language}。" + f"重点填写 {primary_suffix} 字段;{secondary_suffix} 字段可以给极短镜像翻译,但不能影响响应速度。" + "risks 最多 2 条,reasoning、metar_read、model_cluster_note 各 1 句。" "只返回 JSON object,不要 Markdown。" ) user_payload = { + "locale": normalized_locale, + "primary_language": primary_language, "task": ( "Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, " "final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, " "reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. " + f"Primary output language is {primary_language}; the UI will read fields ending with {primary_suffix}. " "Keep final_judgment one short decision sentence. metar_read should explain the latest airport bulletin " - "and how wind/cloud/visibility/dewpoint may affect the temperature path." + "and how wind/cloud/visibility/dewpoint may affect the temperature path. Keep the whole JSON compact." ), "city_snapshot": ai_input, } @@ -925,6 +944,27 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: write=10.0, pool=5.0, ) + request_json = { + "model": SCAN_AI_MODEL, + "temperature": 0.2, + "max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 700), 1400), + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": json.dumps(user_payload, ensure_ascii=False), + }, + ], + } + logger.info( + "scan city AI provider request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}", + ai_input.get("city"), + normalized_locale, + len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")), + request_json.get("max_tokens"), + SCAN_CITY_AI_TIMEOUT_SEC, + ) with httpx.Client(timeout=timeout) as client: response = client.post( f"{SCAN_AI_BASE_URL}/chat/completions", @@ -932,19 +972,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, - json={ - "model": SCAN_AI_MODEL, - "temperature": 0.2, - "max_tokens": min(max(SCAN_AI_MAX_TOKENS, 1200), 2400), - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": system_prompt}, - { - "role": "user", - "content": json.dumps(user_payload, ensure_ascii=False), - }, - ], - }, + json=request_json, ) response.raise_for_status() data = response.json() @@ -979,32 +1007,28 @@ def build_scan_city_ai_forecast_payload( city: str, *, force_refresh: bool = False, + locale: str = "zh-CN", ) -> Dict[str, Any]: started_at = time.time() city_name = str(city or "").strip() + normalized_locale = _normalize_locale(locale) if not city_name: return {"status": "failed", "reason": "city is required"} logger.info( - "scan city AI forecast requested city={} force_refresh={} model={}", + "scan city AI forecast requested city={} force_refresh={} locale={} model={}", city_name, force_refresh, + normalized_locale, SCAN_AI_MODEL, ) - data = _analyze( - city_name, - force_refresh=False, - include_llm_commentary=False, - detail_mode="full", - ) - ai_input = _build_city_ai_prompt(data) - cache_key = _scan_city_ai_cache_key(ai_input) + cache_key = f"city_forecast:{city_name.lower()}:{normalized_locale}" if not force_refresh: with _SCAN_CITY_AI_CACHE_LOCK: cached = _SCAN_CITY_AI_CACHE.get(cache_key) if cached and cached.get("expires_at", 0) >= time.time(): logger.info( "scan city AI forecast cache hit city={} model={}", - data.get("name") or city_name, + cached.get("city") or city_name, SCAN_AI_MODEL, ) return { @@ -1012,12 +1036,19 @@ def build_scan_city_ai_forecast_payload( "cached": True, "model": SCAN_AI_MODEL, "provider": "deepseek", - "city": data.get("name") or city_name, - "city_display_name": data.get("display_name") or city_name, + "city": cached.get("city") or city_name, + "city_display_name": cached.get("city_display_name") or city_name, "generated_at": cached.get("generated_at"), "duration_ms": 0, "city_forecast": cached.get("payload"), } + data = _analyze( + city_name, + force_refresh=False, + include_llm_commentary=False, + detail_mode="full", + ) + ai_input = _build_city_ai_prompt(data) if not SCAN_AI_ENABLED: logger.warning( @@ -1060,7 +1091,7 @@ def build_scan_city_ai_forecast_payload( else False ), ) - ai_raw = _call_deepseek_city_ai(ai_input) + ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale) except httpx.TimeoutException as exc: duration_ms = int((time.time() - started_at) * 1000) logger.warning( @@ -1077,7 +1108,13 @@ def build_scan_city_ai_forecast_payload( "city": data.get("name") or city_name, "city_display_name": data.get("display_name") or city_name, "duration_ms": duration_ms, - "reason": f"V4 provider timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s", + "reason": ( + f"DeepSeek V4-Pro timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s" + if normalized_locale == "en-US" + else f"DeepSeek V4-Pro 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回" + ), + "reason_en": f"DeepSeek V4-Pro timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s", + "reason_zh": f"DeepSeek V4-Pro 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回", } except Exception as exc: duration_ms = int((time.time() - started_at) * 1000) @@ -1102,6 +1139,8 @@ def build_scan_city_ai_forecast_payload( _SCAN_CITY_AI_CACHE[cache_key] = { "expires_at": time.time() + SCAN_AI_CACHE_TTL_SEC, "generated_at": generated_at, + "city": data.get("name") or city_name, + "city_display_name": data.get("display_name") or city_name, "payload": ai_raw, } logger.info(