feat: implement scan terminal service with caching and add corresponding dashboard UI components
This commit is contained in:
@@ -439,6 +439,33 @@ function findDetailForCity(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countDetailModels(detail?: CityDetail | null, targetDate?: string | null) {
|
||||||
|
if (!detail) return 0;
|
||||||
|
const date = String(targetDate || detail.local_date || "").trim();
|
||||||
|
const dailyModels = date ? detail.multi_model_daily?.[date]?.models : null;
|
||||||
|
const models =
|
||||||
|
dailyModels && typeof dailyModels === "object"
|
||||||
|
? dailyModels
|
||||||
|
: detail.multi_model || {};
|
||||||
|
return Object.values(models).filter((value) =>
|
||||||
|
Number.isFinite(Number(value)),
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDetailForecastDays(detail?: CityDetail | null) {
|
||||||
|
const daily = detail?.forecast?.daily;
|
||||||
|
return Array.isArray(daily) ? daily.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullEnoughForDeepAnalysis(detail?: CityDetail | null) {
|
||||||
|
if (!detail) return false;
|
||||||
|
if (detail.detail_depth && detail.detail_depth !== "full") return false;
|
||||||
|
return (
|
||||||
|
countDetailModels(detail, detail.local_date) > 1 &&
|
||||||
|
countDetailForecastDays(detail) > 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
@@ -1187,6 +1214,7 @@ function ScanTerminalScreen() {
|
|||||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||||
const lastMapSelectedCityRef = useRef<string>("");
|
const lastMapSelectedCityRef = useRef<string>("");
|
||||||
|
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const timeSortedRows = useMemo(
|
const timeSortedRows = useMemo(
|
||||||
() => sortRowsByUserTime(terminalData?.rows || []),
|
() => sortRowsByUserTime(terminalData?.rows || []),
|
||||||
@@ -1376,7 +1404,18 @@ function ScanTerminalScreen() {
|
|||||||
}
|
}
|
||||||
return [nextItem, ...current].slice(0, 8);
|
return [nextItem, ...current].slice(0, 8);
|
||||||
});
|
});
|
||||||
void store.ensureCityDetail(cleanName, false, "full").catch(() => {});
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
aiFullHydrationRef.current.add(key);
|
||||||
|
void store
|
||||||
|
.ensureCityDetail(cleanName, true, "full")
|
||||||
|
.then((detail) => {
|
||||||
|
if (!isFullEnoughForDeepAnalysis(detail)) {
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
});
|
||||||
}, [locale, store.ensureCityDetail, timeSortedRows]);
|
}, [locale, store.ensureCityDetail, timeSortedRows]);
|
||||||
|
|
||||||
const removeAiPinnedCity = useCallback((cityName: string) => {
|
const removeAiPinnedCity = useCallback((cityName: string) => {
|
||||||
@@ -1388,9 +1427,17 @@ function ScanTerminalScreen() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aiPinnedCities.forEach((item) => {
|
aiPinnedCities.forEach((item) => {
|
||||||
if (!findDetailForCity(store.cityDetailsByName, item.cityName)) {
|
const key = normalizeCityKey(item.cityName);
|
||||||
void store.ensureCityDetail(item.cityName, false, "full").catch(() => {});
|
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||||
}
|
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
||||||
|
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
||||||
|
if (!needsFullHydration) return;
|
||||||
|
aiFullHydrationRef.current.add(key);
|
||||||
|
void store
|
||||||
|
.ensureCityDetail(item.cityName, Boolean(detail), "full")
|
||||||
|
.catch(() => {
|
||||||
|
aiFullHydrationRef.current.delete(key);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [aiPinnedCities, store.cityDetailsByName, store.ensureCityDetail]);
|
}, [aiPinnedCities, store.cityDetailsByName, store.ensureCityDetail]);
|
||||||
|
|
||||||
|
|||||||
@@ -841,9 +841,7 @@ export function DashboardStoreProvider({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cached) {
|
setLoadingState((current) => ({ ...current, cityDetail: !cached }));
|
||||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
|
||||||
}
|
|
||||||
const detailPromise = ensureCityDetail(cityName, false, "panel");
|
const detailPromise = ensureCityDetail(cityName, false, "panel");
|
||||||
void Promise.allSettled([summaryPromise, detailPromise])
|
void Promise.allSettled([summaryPromise, detailPromise])
|
||||||
.then(([, detail]) => {
|
.then(([, detail]) => {
|
||||||
@@ -854,20 +852,19 @@ export function DashboardStoreProvider({
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (selectedCityRef.current !== cityName) return;
|
if (selectedCityRef.current !== cityName) return;
|
||||||
if (!cached) {
|
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusCity = async (cityName: string) => {
|
const focusCity = async (cityName: string) => {
|
||||||
|
const cached = cityDetailsByName[cityName];
|
||||||
selectedCityRef.current = cityName;
|
selectedCityRef.current = cityName;
|
||||||
setSelectedCity(cityName);
|
setSelectedCity(cityName);
|
||||||
setIsPanelOpen(false);
|
setIsPanelOpen(false);
|
||||||
setSelectedForecastDate(null);
|
setSelectedForecastDate(null);
|
||||||
setFutureModalDate(null);
|
setFutureModalDate(null);
|
||||||
setForecastModalMode(null);
|
setForecastModalMode(null);
|
||||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
setLoadingState((current) => ({ ...current, cityDetail: !cached }));
|
||||||
void Promise.allSettled([
|
void Promise.allSettled([
|
||||||
ensureCitySummary(cityName),
|
ensureCitySummary(cityName),
|
||||||
ensureCityDetail(cityName, false, "panel"),
|
ensureCityDetail(cityName, false, "panel"),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ SCAN_CITY_AI_MAX_TOKENS = max(
|
|||||||
8192,
|
8192,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", "8192")),
|
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", "8192")),
|
||||||
)
|
)
|
||||||
|
SCAN_CITY_AI_PROMPT_VERSION = "city-airport-read-v2"
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: Any) -> Optional[float]:
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
@@ -996,7 +997,8 @@ def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
risk = data.get("risk") if isinstance(data.get("risk"), dict) else {}
|
risk = data.get("risk") if isinstance(data.get("risk"), dict) else {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"schema_version": "single_city_forecast_v1",
|
"schema_version": "single_city_forecast_v2",
|
||||||
|
"prompt_version": SCAN_CITY_AI_PROMPT_VERSION,
|
||||||
"task": "predict_city_daily_high_and_read_metar",
|
"task": "predict_city_daily_high_and_read_metar",
|
||||||
"city": data.get("name"),
|
"city": data.get("name"),
|
||||||
"city_display_name": data.get("display_name") or data.get("name"),
|
"city_display_name": data.get("display_name") or data.get("name"),
|
||||||
@@ -1077,10 +1079,15 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
"如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。"
|
"如果实测温度与 DEB 预测走势出现偏差,要明确说明偏差方向和可能修正。"
|
||||||
"你可以基于城市、时间、季节、机场位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径,"
|
"你可以基于城市、时间、季节、机场位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径,"
|
||||||
"但必须使用“可能”“倾向”“需要确认”等非绝对表达。"
|
"但必须使用“可能”“倾向”“需要确认”等非绝对表达。"
|
||||||
|
"METAR 解读必须具体:写清楚最新报文时间、温度、风向风速、云量/天气、能见度或露点中与温度路径相关的因素。"
|
||||||
|
"涉及风时必须说明该风向对本城市/机场最高温路径倾向增温、降温还是中性,并给出理由;"
|
||||||
|
"不得只写“风向切换可能冷平流”,必须说明是哪一类风向或哪段风向切换可能带来冷/暖平流。"
|
||||||
|
"涉及 TAF 或云雨扰动时必须给出报文中的有效时间、BECMG/TEMPO/FM 时间窗或说明“未给出明确时间”;"
|
||||||
|
"如果没有 TAF 时间依据,不要笼统写“峰值窗口云雨扰动风险”。"
|
||||||
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。"
|
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。"
|
||||||
f"当前用户界面语言是 {normalized_locale},所有面向用户的主要自然语言字段必须使用 {primary_language}。"
|
f"当前用户界面语言是 {normalized_locale},所有面向用户的主要自然语言字段必须使用 {primary_language}。"
|
||||||
f"重点填写 {primary_suffix} 字段;{secondary_suffix} 字段只填空字符串,前端不读取它。"
|
f"重点填写 {primary_suffix} 字段;{secondary_suffix} 字段只填空字符串,前端不读取它。"
|
||||||
"risks 最多 2 条,每条不超过 18 个汉字或 12 个英文词;reasoning、metar_read、model_cluster_note 各 1 句。"
|
"risks 最多 2 条,每条必须包含触发条件或方向来源;reasoning、model_cluster_note 各 1 句,metar_read 可用 2-4 句。"
|
||||||
"只返回 JSON object,不要 Markdown。"
|
"只返回 JSON object,不要 Markdown。"
|
||||||
)
|
)
|
||||||
user_payload = {
|
user_payload = {
|
||||||
@@ -1092,8 +1099,12 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_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}. "
|
f"Primary output language is {primary_language}; the UI will read fields ending with {primary_suffix}. "
|
||||||
f"Fields ending with {secondary_suffix} must be empty strings or empty arrays to avoid truncation. "
|
f"Fields ending with {secondary_suffix} must be empty strings or empty arrays to avoid truncation. "
|
||||||
"Keep final_judgment one short decision sentence. metar_read should explain the latest airport bulletin "
|
"Keep final_judgment one short decision sentence. metar_read must explain the latest airport bulletin "
|
||||||
"and how wind/cloud/visibility/dewpoint may affect the temperature path. model_cluster_note must state "
|
"with report time, temperature, wind direction/speed, cloud/weather/visibility/dewpoint if available. "
|
||||||
|
"For wind, explicitly say whether the current wind tends to warm, cool, or be neutral for today's high, "
|
||||||
|
"and why in local city/airport context. If mentioning cold/warm advection, name the wind direction or "
|
||||||
|
"direction shift responsible. If mentioning TAF risk, include the concrete TAF time window or say no "
|
||||||
|
"explicit timing is available. model_cluster_note must state "
|
||||||
"how many model sources are available, whether they support DEB, and whether the sample is too sparse. "
|
"how many model sources are available, whether they support DEB, and whether the sample is too sparse. "
|
||||||
"Keep the whole JSON compact."
|
"Keep the whole JSON compact."
|
||||||
),
|
),
|
||||||
@@ -1238,7 +1249,11 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
"instruction": (
|
"instruction": (
|
||||||
f"Primary UI language is {primary_language}. "
|
f"Primary UI language is {primary_language}. "
|
||||||
"Make final_judgment one direct sentence about today's high temperature. "
|
"Make final_judgment one direct sentence about today's high temperature. "
|
||||||
"metar_read must interpret the latest airport bulletin and possible temperature-path impact. "
|
"metar_read must interpret the latest airport bulletin with report time, temperature, "
|
||||||
|
"wind direction/speed, cloud/weather/visibility/dewpoint if available. State whether "
|
||||||
|
"the current wind tends to warm, cool, or stay neutral for the temperature path, and why. "
|
||||||
|
"If mentioning cold/warm advection or TAF risk, include the responsible wind direction "
|
||||||
|
"or the concrete TAF time window; otherwise say timing is not explicit. "
|
||||||
"model_cluster_note must mention available model count/range and whether it supports DEB. "
|
"model_cluster_note must mention available model count/range and whether it supports DEB. "
|
||||||
"Keep the JSON compact."
|
"Keep the JSON compact."
|
||||||
),
|
),
|
||||||
@@ -1287,6 +1302,8 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -
|
|||||||
|
|
||||||
def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str:
|
def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str:
|
||||||
key_payload = {
|
key_payload = {
|
||||||
|
"prompt_version": SCAN_CITY_AI_PROMPT_VERSION,
|
||||||
|
"schema_version": ai_input.get("schema_version"),
|
||||||
"city": ai_input.get("city"),
|
"city": ai_input.get("city"),
|
||||||
"local_date": ai_input.get("local_date"),
|
"local_date": ai_input.get("local_date"),
|
||||||
"local_time": ai_input.get("local_time"),
|
"local_time": ai_input.get("local_time"),
|
||||||
@@ -1331,7 +1348,7 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
normalized_locale,
|
normalized_locale,
|
||||||
SCAN_AI_MODEL,
|
SCAN_AI_MODEL,
|
||||||
)
|
)
|
||||||
cache_key = f"city_forecast:{city_name.lower()}:{normalized_locale}"
|
cache_key = f"city_forecast:{SCAN_CITY_AI_PROMPT_VERSION}:{city_name.lower()}:{normalized_locale}"
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
with _SCAN_CITY_AI_CACHE_LOCK:
|
with _SCAN_CITY_AI_CACHE_LOCK:
|
||||||
cached = _SCAN_CITY_AI_CACHE.get(cache_key)
|
cached = _SCAN_CITY_AI_CACHE.get(cache_key)
|
||||||
|
|||||||
Reference in New Issue
Block a user