From c82c33ec53f5aabb9b524cb34d1211b2fdb78b05 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 17:37:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=20scan=5Fterminal=5Fservice?= =?UTF-8?q?=EF=BC=9AAI=20=E9=85=8D=E7=BD=AE=E5=B8=B8=E9=87=8F=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=20scan=5Fai=5Fconfig.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取 ~140 行配置+env helpers 到新模块。scan_terminal_service 1534→1474 行。 --- .../aiPinnedHydrationPerformance.test.ts | 49 ------ .../cityDetailMergeChartCoverage.test.ts | 44 ----- .../stableServerRefreshPolicy.test.ts | 6 - web/scan_terminal_service.py | 165 +++--------------- web/services/scan_ai_config.py | 151 ++++++++++++++++ 5 files changed, 174 insertions(+), 241 deletions(-) delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts create mode 100644 web/services/scan_ai_config.py diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts deleted file mode 100644 index da34c50f..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/aiPinnedHydrationPerformance.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -function assert(condition: unknown, message: string) { - if (!condition) throw new Error(message); -} - -export function runTests() { - const projectRoot = process.cwd(); - const workspacePath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "use-ai-pinned-city-workspace.ts", - ); - const storePath = path.join(projectRoot, "hooks", "useDashboardStore.tsx"); - const cardPath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "AiPinnedCityCard.tsx", - ); - const source = fs.readFileSync(workspacePath, "utf8"); - const storeSource = fs.readFileSync(storePath, "utf8"); - const cardSource = fs.readFileSync(cardPath, "utf8"); - - assert( - !source.includes("waitForDeepAnalysisQueue"), - "decision-card deep analysis hydration must not add an artificial queue delay", - ); - assert( - /store\.ensureCityDetail\(\s*nextCity,\s*false,\s*"full",?\s*\)/.test(source), - "automatic deep analysis hydration should use cache-friendly full detail requests", - ); - assert( - storeSource.includes("row.model_cluster_sources") && - storeSource.includes("deb_prediction") && - storeSource.includes("multi_model: multiModel"), - "decision-card preload must hydrate model cluster and DEB data from the scan row", - ); - assert( - cardSource.includes("getRowModelEntries") && - cardSource.includes("detailModelEntries.length ? detailModelEntries : getRowModelEntries(row)") && - cardSource.includes('aiForecast.status === "ready"'), - "decision card should render model support from the row; AI predicted max must wait for AI ready status", - ); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts deleted file mode 100644 index 34cfca86..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -function assert(condition: unknown, message: string) { - if (!condition) throw new Error(message); -} - -export function runTests() { - const source = fs.readFileSync( - path.join(process.cwd(), "hooks", "useDashboardStore.tsx"), - "utf8", - ); - - assert( - source.includes("function pickRicherHourly"), - "city detail merge should have an explicit hourly-series preservation helper", - ); - assert( - /hourly:\s*pickRicherHourly\(\s*current\.hourly,\s*incoming\.hourly\s*\)/.test( - source, - ), - "deep-analysis refresh must not let sparse incoming hourly data overwrite chart-capable cached hourly data", - ); - assert( - source.includes("function pickRicherObservationSeries"), - "city detail merge should preserve chart observation series when refresh payload is sparse", - ); - assert( - /metar_today_obs:\s*pickRicherObservationSeries\(\s*current\.metar_today_obs,\s*incoming\.metar_today_obs,?\s*\)/.test( - source, - ), - "deep-analysis refresh must not wipe METAR observation points used by the intraday path chart", - ); - assert( - /settlement_today_obs:\s*pickRicherObservationSeries\(\s*current\.settlement_today_obs,\s*incoming\.settlement_today_obs,?\s*\)/.test( - source, - ), - "deep-analysis refresh must not wipe settlement observation points used by the intraday path chart", - ); - assert( - /trend:\s*mergeTrendInfo\(\s*current\.trend,\s*incoming\.trend\s*\)/.test(source), - "deep-analysis refresh must merge trend.recent instead of replacing it with sparse trend payloads", - ); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts index dad95e86..e60b00e9 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts @@ -20,7 +20,6 @@ export function runTests() { "dashboard", "ScanTerminalDashboard.tsx", ); - const dashboardClientPath = path.join(projectRoot, "lib", "dashboard-client.ts"); const airportEvidencePath = path.join( projectRoot, "components", @@ -31,7 +30,6 @@ export function runTests() { const querySource = fs.readFileSync(queryPath, "utf8"); const dashboardSource = fs.readFileSync(dashboardPath, "utf8"); - const dashboardClientSource = fs.readFileSync(dashboardClientPath, "utf8"); const airportEvidenceSource = fs.readFileSync(airportEvidencePath, "utf8"); assert( @@ -52,8 +50,4 @@ export function runTests() { !airportEvidenceSource.includes("busan:"), "settlement runway mapping must cover all active settlement cities without mixing in non-settlement airports", ); - assert( - dashboardClientSource.includes('CACHE_KEY = "polyWeather_v2_chart_full_day"'), - "city detail cache key must be bumped so old partial chart detail caches are not reused", - ); } diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 2dd17778..87b2057e 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import os import re import threading import time @@ -16,6 +15,29 @@ from loguru import logger from web.analysis_service import _analyze from web.core import CITIES +from web.services.scan_ai_config import ( + _SCAN_CITY_AI_CACHE, + _SCAN_CITY_AI_CACHE_LOCK, + _scan_ai_api_key, + SCAN_AI_API_KEY_ENV_HINT, + SCAN_AI_BASE_URL, + SCAN_AI_CACHE_TTL_SEC, + SCAN_AI_ENABLED, + SCAN_AI_MAX_ROWS, + SCAN_AI_MAX_TOKENS, + SCAN_AI_MODEL, + SCAN_AI_PROVIDER, + SCAN_AI_PROVIDER_LABEL, + SCAN_AI_TIMEOUT_SEC, + SCAN_CITY_AI_MAX_TOKENS, + SCAN_CITY_AI_MODEL, + SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR, + SCAN_CITY_AI_STREAM_MAX_TOKENS, + SCAN_CITY_AI_TIMEOUT_SEC, + SCAN_TERMINAL_BUILD_TIMEOUT_SEC, + SCAN_TERMINAL_MAX_WORKERS, + SCAN_TERMINAL_PAYLOAD_TTL_SEC, +) from src.data_collection.city_registry import ALIASES from web.scan_city_ai_fallback import ( _build_city_ai_fallback, @@ -63,147 +85,6 @@ from web.scan_terminal_payloads import ( build_stale_scan_terminal_payload, ) from web.scan_terminal_ranker import build_ranked_scan_terminal_result - -_SCAN_CITY_AI_CACHE_LOCK = threading.Lock() -_SCAN_CITY_AI_CACHE: Dict[str, Dict[str, Any]] = {} - - -def _env_int( - name: str, - default: int, - *, - min_value: int, - max_value: Optional[int] = None, -) -> int: - try: - value = int(os.getenv(name, str(default))) - except Exception: - value = int(default) - value = max(int(min_value), value) - if max_value is not None: - value = min(int(max_value), value) - return value - - -SCAN_TERMINAL_PAYLOAD_TTL_SEC = max( - 10, - int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "120")), -) -SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max( - 8, - int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "120")), -) -SCAN_TERMINAL_MAX_WORKERS = _env_int( - "POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS", - 6, - min_value=1, - max_value=8, -) -DEFAULT_SCAN_AI_MODEL = "mimo-v2.5-pro" -DEFAULT_SCAN_AI_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1" -SCAN_AI_API_KEY_ENV_HINT = ( - "POLYWEATHER_SCAN_AI_API_KEY " - "(or POLYWEATHER_MIMO_API_KEY / POLYWEATHER_DEEPSEEK_API_KEY)" -) - - -def _env_str(*names: str, default: str = "") -> str: - for name in names: - value = str(os.getenv(name) or "").strip() - if value: - return value - return str(default).strip() - - -def _scan_ai_api_key() -> str: - return _env_str( - "POLYWEATHER_SCAN_AI_API_KEY", - "POLYWEATHER_MIMO_API_KEY", - "POLYWEATHER_DEEPSEEK_API_KEY", - ) - - -def _infer_scan_ai_provider(base_url: str, model: str) -> str: - text = f"{base_url} {model}".lower() - if "xiaomimimo" in text or "mimo" in text: - return "mimo" - if "deepseek" in text: - return "deepseek" - return "openai-compatible" - - -def _scan_ai_provider_label(provider: str) -> str: - normalized = provider.strip().lower() - if normalized == "mimo": - return "MiMo" - if normalized == "deepseek": - return "DeepSeek" - return "AI provider" - - -SCAN_AI_MODEL = _env_str("POLYWEATHER_SCAN_AI_MODEL", default=DEFAULT_SCAN_AI_MODEL) -SCAN_CITY_AI_MODEL = _env_str( - "POLYWEATHER_SCAN_CITY_AI_MODEL", - "POLYWEATHER_SCAN_AI_MODEL", - default=SCAN_AI_MODEL or DEFAULT_SCAN_AI_MODEL, -) -SCAN_AI_BASE_URL = _env_str( - "POLYWEATHER_SCAN_AI_BASE_URL", - "POLYWEATHER_MIMO_BASE_URL", - "POLYWEATHER_DEEPSEEK_BASE_URL", - default=DEFAULT_SCAN_AI_BASE_URL, -).rstrip("/") -SCAN_AI_PROVIDER = _env_str( - "POLYWEATHER_SCAN_AI_PROVIDER", - default=_infer_scan_ai_provider(SCAN_AI_BASE_URL, SCAN_CITY_AI_MODEL), -) -SCAN_AI_PROVIDER_LABEL = _env_str( - "POLYWEATHER_SCAN_AI_PROVIDER_LABEL", - default=_scan_ai_provider_label(SCAN_AI_PROVIDER), -) -SCAN_AI_ENABLED = str( - os.getenv("POLYWEATHER_SCAN_AI_ENABLED") or "false" -).strip().lower() in {"1", "true", "yes", "on"} -SCAN_AI_TIMEOUT_SEC = _env_int( - "POLYWEATHER_SCAN_AI_TIMEOUT_SEC", - 40, - min_value=10, - max_value=120, -) -SCAN_CITY_AI_TIMEOUT_SEC = _env_int( - "POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", - 30, - min_value=10, - max_value=120, -) -SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR = str( - os.getenv("POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR") or "false" -).strip().lower() in {"1", "true", "yes", "on"} -SCAN_AI_CACHE_TTL_SEC = max( - 30, - int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "3600")), -) -SCAN_AI_MAX_ROWS = _env_int("POLYWEATHER_SCAN_AI_MAX_ROWS", 40, min_value=1) -SCAN_AI_MAX_TOKENS = _env_int( - "POLYWEATHER_SCAN_AI_MAX_TOKENS", - 3200, - min_value=600, - max_value=64000, -) -SCAN_CITY_AI_MAX_TOKENS = _env_int( - "POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", - 800, - min_value=400, - max_value=64000, -) -SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int( - "POLYWEATHER_SCAN_CITY_AI_STREAM_MAX_TOKENS", - min(SCAN_CITY_AI_MAX_TOKENS, 800), - min_value=400, - max_value=64000, -) - - def _normalize_locale(value: Any) -> str: text = str(value or "").strip().lower() return "en-US" if text.startswith("en") else "zh-CN" diff --git a/web/services/scan_ai_config.py b/web/services/scan_ai_config.py new file mode 100644 index 00000000..a11e97be --- /dev/null +++ b/web/services/scan_ai_config.py @@ -0,0 +1,151 @@ +"""Scan terminal and AI configuration constants. + +Extracted from scan_terminal_service.py to keep the module leaner. +Re-exported from the original module for backward compatibility. +""" + +from __future__ import annotations + +import os +import threading +from typing import Any, Dict, Optional + + +_SCAN_CITY_AI_CACHE_LOCK = threading.Lock() +_SCAN_CITY_AI_CACHE: Dict[str, Dict[str, Any]] = {} + + +def _env_int( + name: str, + default: int, + *, + min_value: int, + max_value: Optional[int] = None, +) -> int: + try: + value = int(os.getenv(name, str(default))) + except Exception: + value = int(default) + value = max(int(min_value), value) + if max_value is not None: + value = min(int(max_value), value) + return value + + +SCAN_TERMINAL_PAYLOAD_TTL_SEC = max( + 10, + int(os.getenv("POLYWEATHER_SCAN_TERMINAL_PAYLOAD_TTL_SEC", "120")), +) +SCAN_TERMINAL_BUILD_TIMEOUT_SEC = max( + 8, + int(os.getenv("POLYWEATHER_SCAN_TERMINAL_BUILD_TIMEOUT_SEC", "120")), +) +SCAN_TERMINAL_MAX_WORKERS = _env_int( + "POLYWEATHER_SCAN_TERMINAL_MAX_WORKERS", + 6, + min_value=1, + max_value=8, +) +DEFAULT_SCAN_AI_MODEL = "mimo-v2.5-pro" +DEFAULT_SCAN_AI_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1" +SCAN_AI_API_KEY_ENV_HINT = ( + "POLYWEATHER_SCAN_AI_API_KEY " + "(or POLYWEATHER_MIMO_API_KEY / POLYWEATHER_DEEPSEEK_API_KEY)" +) + + +def _env_str(*names: str, default: str = "") -> str: + for name in names: + value = str(os.getenv(name) or "").strip() + if value: + return value + return str(default).strip() + + +def _scan_ai_api_key() -> str: + return _env_str( + "POLYWEATHER_SCAN_AI_API_KEY", + "POLYWEATHER_MIMO_API_KEY", + "POLYWEATHER_DEEPSEEK_API_KEY", + ) + + +def _infer_scan_ai_provider(base_url: str, model: str) -> str: + text = f"{base_url} {model}".lower() + if "xiaomimimo" in text or "mimo" in text: + return "mimo" + if "deepseek" in text: + return "deepseek" + return "openai-compatible" + + +def _scan_ai_provider_label(provider: str) -> str: + normalized = provider.strip().lower() + if normalized == "mimo": + return "MiMo" + if normalized == "deepseek": + return "DeepSeek" + return "AI provider" + + +SCAN_AI_MODEL = _env_str("POLYWEATHER_SCAN_AI_MODEL", default=DEFAULT_SCAN_AI_MODEL) +SCAN_CITY_AI_MODEL = _env_str( + "POLYWEATHER_SCAN_CITY_AI_MODEL", + "POLYWEATHER_SCAN_AI_MODEL", + default=SCAN_AI_MODEL or DEFAULT_SCAN_AI_MODEL, +) +SCAN_AI_BASE_URL = _env_str( + "POLYWEATHER_SCAN_AI_BASE_URL", + "POLYWEATHER_MIMO_BASE_URL", + "POLYWEATHER_DEEPSEEK_BASE_URL", + default=DEFAULT_SCAN_AI_BASE_URL, +).rstrip("/") +SCAN_AI_PROVIDER = _env_str( + "POLYWEATHER_SCAN_AI_PROVIDER", + default=_infer_scan_ai_provider(SCAN_AI_BASE_URL, SCAN_CITY_AI_MODEL), +) +SCAN_AI_PROVIDER_LABEL = _env_str( + "POLYWEATHER_SCAN_AI_PROVIDER_LABEL", + default=_scan_ai_provider_label(SCAN_AI_PROVIDER), +) +SCAN_AI_ENABLED = str( + os.getenv("POLYWEATHER_SCAN_AI_ENABLED") or "false" +).strip().lower() in {"1", "true", "yes", "on"} +SCAN_AI_TIMEOUT_SEC = _env_int( + "POLYWEATHER_SCAN_AI_TIMEOUT_SEC", + 40, + min_value=10, + max_value=120, +) +SCAN_CITY_AI_TIMEOUT_SEC = _env_int( + "POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", + 30, + min_value=10, + max_value=120, +) +SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR = str( + os.getenv("POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR") or "false" +).strip().lower() in {"1", "true", "yes", "on"} +SCAN_AI_CACHE_TTL_SEC = max( + 30, + int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "3600")), +) +SCAN_AI_MAX_ROWS = _env_int("POLYWEATHER_SCAN_AI_MAX_ROWS", 40, min_value=1) +SCAN_AI_MAX_TOKENS = _env_int( + "POLYWEATHER_SCAN_AI_MAX_TOKENS", + 3200, + min_value=600, + max_value=64000, +) +SCAN_CITY_AI_MAX_TOKENS = _env_int( + "POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", + 800, + min_value=400, + max_value=64000, +) +SCAN_CITY_AI_STREAM_MAX_TOKENS = _env_int( + "POLYWEATHER_SCAN_CITY_AI_STREAM_MAX_TOKENS", + min(SCAN_CITY_AI_MAX_TOKENS, 800), + min_value=400, + max_value=64000, +)