拆分 scan_terminal_service:AI 配置常量移至 scan_ai_config.py

提取 ~140 行配置+env helpers 到新模块。scan_terminal_service 1534→1474 行。
This commit is contained in:
2569718930@qq.com
2026-05-25 17:37:08 +08:00
parent 623ec7c5fa
commit c82c33ec53
5 changed files with 174 additions and 241 deletions
@@ -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",
);
}
@@ -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",
);
}
@@ -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",
);
}
+23 -142
View File
@@ -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"
+151
View File
@@ -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,
)