feat: implement AI-driven weather scan terminal with decision utilities and forecast visualization
This commit is contained in:
@@ -83,6 +83,17 @@ function normalizeMetarReadTime(text: string, displayTime: string, isEn: boolean
|
||||
.replace(/\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, `at ${displayTime}`);
|
||||
}
|
||||
|
||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||
const source = String(
|
||||
detail?.current?.settlement_source ||
|
||||
detail?.settlement_station?.settlement_source ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return source === "hko";
|
||||
}
|
||||
|
||||
function AiPinnedCityCard({
|
||||
item,
|
||||
detail,
|
||||
@@ -127,12 +138,14 @@ function AiPinnedCityCard({
|
||||
(row ? getPeakWindowLabel(row) : null) ||
|
||||
"--";
|
||||
const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null;
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const currentTemp =
|
||||
detail?.airport_primary?.temp ??
|
||||
detail?.airport_current?.temp ??
|
||||
detail?.current?.temp ??
|
||||
row?.current_temp ??
|
||||
null;
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp ?? row?.current_temp
|
||||
: detail?.airport_primary?.temp ??
|
||||
detail?.airport_current?.temp ??
|
||||
detail?.current?.temp ??
|
||||
row?.current_temp) ?? null;
|
||||
const debNumber = toFiniteDecisionNumber(deb);
|
||||
const currentTempNumber = toFiniteDecisionNumber(currentTemp);
|
||||
const modelRange =
|
||||
@@ -145,14 +158,30 @@ function AiPinnedCityCard({
|
||||
(isEn
|
||||
? "Waiting for intraday observations to compare against the DEB path."
|
||||
: "等待更多日内实测,用来对照 DEB 预测路径。");
|
||||
const report = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
|
||||
const report = isHkoObservation
|
||||
? ""
|
||||
: detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
|
||||
const metarReportTimeDisplay = formatMetarReportTime(detail, report, isEn);
|
||||
const airportStation =
|
||||
detail?.risk?.icao ||
|
||||
detail?.current?.station_code ||
|
||||
detail?.airport_current?.station_code ||
|
||||
detail?.airport_primary?.station_code ||
|
||||
"";
|
||||
const observationStation = isHkoObservation
|
||||
? detail?.current?.station_name ||
|
||||
detail?.current?.station_code ||
|
||||
detail?.settlement_station?.settlement_station_label ||
|
||||
detail?.settlement_station?.settlement_station_code ||
|
||||
"香港天文台"
|
||||
: detail?.risk?.icao ||
|
||||
detail?.current?.station_code ||
|
||||
detail?.airport_current?.station_code ||
|
||||
detail?.airport_primary?.station_code ||
|
||||
"";
|
||||
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
|
||||
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
|
||||
const rawObservationText = isHkoObservation
|
||||
? `${isEn ? "Observation source" : "观测来源"}:${observationStation || (isEn ? "Hong Kong Observatory" : "香港天文台")}${metarReportTimeDisplay ? `,${metarReportTimeDisplay}` : ""}`
|
||||
: report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${observationStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR: unavailable."
|
||||
: "原始 METAR:暂无。";
|
||||
const detailCityName = detail?.name || item.cityName;
|
||||
const [refreshingDetail, setRefreshingDetail] = useState(false);
|
||||
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
||||
@@ -207,14 +236,14 @@ function AiPinnedCityCard({
|
||||
const localModelSupportNote = modelEntries.length
|
||||
? isEn
|
||||
? modelEntries.length <= 2
|
||||
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and METAR.`
|
||||
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and ${observationSourceEn}.`
|
||||
: `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.`
|
||||
: modelEntries.length <= 2
|
||||
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和 METAR 实测。`
|
||||
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和${observationSourceZh}。`
|
||||
: `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。`
|
||||
: isEn
|
||||
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
|
||||
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
|
||||
? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.`
|
||||
: `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`;
|
||||
const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max);
|
||||
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
|
||||
aiPredictedMax,
|
||||
@@ -372,7 +401,7 @@ function AiPinnedCityCard({
|
||||
{isEn ? "Bucket" : "温度桶"} <b>{marketDecisionView.bucketLabel}</b>
|
||||
</small>
|
||||
<small>
|
||||
{isEn ? "YES" : "YES 买入"} <b>{marketDecisionView.priceText}</b>
|
||||
{isEn ? "YES buy" : "YES 买价"} <b>{marketDecisionView.priceText}</b>
|
||||
</small>
|
||||
<small>
|
||||
{isEn ? "Model-market" : "模型-市场差"} <b>{marketDecisionView.edgeText}</b>
|
||||
@@ -433,7 +462,13 @@ function AiPinnedCityCard({
|
||||
<AiCityTemperatureChart detail={detail} />
|
||||
<section className="scan-ai-city-section">
|
||||
<div className="scan-ai-city-section-title">
|
||||
{isEn ? "Evidence · AI airport read" : "证据 · AI 机场报文解读"}
|
||||
{isHkoObservation
|
||||
? isEn
|
||||
? "Evidence · AI HKO observation read"
|
||||
: "证据 · AI 香港天文台观测解读"
|
||||
: isEn
|
||||
? "Evidence · AI airport read"
|
||||
: "证据 · AI 机场报文解读"}
|
||||
</div>
|
||||
{aiForecast.status === "loading" ? (
|
||||
<>
|
||||
@@ -441,13 +476,21 @@ function AiPinnedCityCard({
|
||||
{localizedFinalJudgment ||
|
||||
aiForecast.streamText ||
|
||||
(isEn
|
||||
? "DeepSeek is reading the airport bulletin and city context..."
|
||||
: "DeepSeek 正在统一解读机场报文和城市上下文…")}
|
||||
? isHkoObservation
|
||||
? "DeepSeek is reading the HKO observation and city context..."
|
||||
: "DeepSeek is reading the airport bulletin and city context..."
|
||||
: isHkoObservation
|
||||
? "DeepSeek 正在统一解读香港天文台观测和城市上下文…"
|
||||
: "DeepSeek 正在统一解读机场报文和城市上下文…")}
|
||||
</p>
|
||||
<p className="scan-ai-city-muted">
|
||||
{isEn
|
||||
? "One v4-flash stream now drives both the airport read and city judgment."
|
||||
: "现在由 v4-flash 一条流同时生成机场报文解读和城市判断。"}
|
||||
? isHkoObservation
|
||||
? "One v4-flash stream now drives both the HKO observation read and city judgment."
|
||||
: "One v4-flash stream now drives both the airport read and city judgment."
|
||||
: isHkoObservation
|
||||
? "现在由 v4-flash 一条流同时生成香港天文台观测解读和城市判断。"
|
||||
: "现在由 v4-flash 一条流同时生成机场报文解读和城市判断。"}
|
||||
</p>
|
||||
</>
|
||||
) : aiForecast.status === "ready" && aiCityForecast ? (
|
||||
@@ -462,11 +505,7 @@ function AiPinnedCityCard({
|
||||
))}
|
||||
</ul>
|
||||
<p className="scan-ai-raw-metar">
|
||||
{report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR: unavailable."
|
||||
: "原始 METAR:暂无。"}
|
||||
{rawObservationText}
|
||||
</p>
|
||||
</>
|
||||
) : aiForecast.status === "ready" ? (
|
||||
@@ -483,39 +522,35 @@ function AiPinnedCityCard({
|
||||
</p>
|
||||
<ul className="scan-ai-weather-bullets">
|
||||
<li>{localModelSupportNote}</li>
|
||||
<li>
|
||||
{report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR is unavailable."
|
||||
: "暂无原始 METAR。"}
|
||||
</li>
|
||||
<li>{rawObservationText}</li>
|
||||
</ul>
|
||||
</>
|
||||
) : aiForecast.status === "failed" ? (
|
||||
<>
|
||||
<p>
|
||||
{isEn
|
||||
? "AI read failed. Model support and the raw METAR remain as fallback context."
|
||||
: "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"}
|
||||
? isHkoObservation
|
||||
? "AI read failed. Model support and the HKO observation remain as fallback context."
|
||||
: "AI read failed. Model support and the raw METAR remain as fallback context."
|
||||
: isHkoObservation
|
||||
? "AI 解读失败。下方保留多模型支撑和香港天文台观测作为兜底上下文。"
|
||||
: "AI 解读失败。下方保留多模型支撑和原始 METAR 作为兜底上下文。"}
|
||||
{aiForecast.error ? ` ${aiForecast.error}` : ""}
|
||||
</p>
|
||||
<ul className="scan-ai-weather-bullets">
|
||||
<li>{localModelSupportNote}</li>
|
||||
<li>
|
||||
{report
|
||||
? `${isEn ? "Raw METAR" : "原始 METAR"}:${`${airportStation} ${report}`.trim()}`
|
||||
: isEn
|
||||
? "Raw METAR is unavailable."
|
||||
: "暂无原始 METAR。"}
|
||||
</li>
|
||||
<li>{rawObservationText}</li>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{isEn
|
||||
? "Waiting for AI to read the latest airport bulletin."
|
||||
: "等待 AI 解读最新机场报文。"}
|
||||
? isHkoObservation
|
||||
? "Waiting for AI to read the latest HKO observation."
|
||||
: "Waiting for AI to read the latest airport bulletin."
|
||||
: isHkoObservation
|
||||
? "等待 AI 解读最新香港天文台观测。"
|
||||
: "等待 AI 解读最新机场报文。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
@@ -535,8 +570,12 @@ function AiPinnedCityCard({
|
||||
title={isEn ? "Loading city decision data" : "正在加载城市决策数据"}
|
||||
description={
|
||||
isEn
|
||||
? "Hydrating today’s model stack, METAR context and market layer."
|
||||
: "正在补全今日模型、机场报文和市场价格层。"
|
||||
? isHkoObservation
|
||||
? "Hydrating today’s model stack, HKO observation context and market layer."
|
||||
: "Hydrating today’s model stack, METAR context and market layer."
|
||||
: isHkoObservation
|
||||
? "正在补全今日模型、香港天文台观测和市场价格层。"
|
||||
: "正在补全今日模型、机场报文和市场价格层。"
|
||||
}
|
||||
compact
|
||||
/>
|
||||
|
||||
@@ -203,6 +203,42 @@ function getBucketModelProbability(bucket?: MarketTopBucket | null) {
|
||||
return model ?? probability;
|
||||
}
|
||||
|
||||
function getBucketDisplayUnit(bucket: MarketTopBucket, tempSymbol: string) {
|
||||
return bucket.unit
|
||||
? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
|
||||
: tempSymbol;
|
||||
}
|
||||
|
||||
function buildBucketMappingExplanation({
|
||||
bucket,
|
||||
expectedHigh,
|
||||
isEn,
|
||||
tempSymbol,
|
||||
}: {
|
||||
bucket: MarketTopBucket;
|
||||
expectedHigh: number | null;
|
||||
isEn: boolean;
|
||||
tempSymbol: string;
|
||||
}) {
|
||||
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
|
||||
const rounded = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket);
|
||||
if (comparable == null || rounded == null) return "";
|
||||
const unit = getBucketDisplayUnit(bucket, tempSymbol);
|
||||
const bucketLabel = getMarketBucketLabel(bucket, tempSymbol);
|
||||
const expectedText = formatTemperatureValue(comparable, unit, { digits: 1 });
|
||||
const hasRounding = Math.abs(comparable - rounded) >= 0.05;
|
||||
if (isEn) {
|
||||
const mapping = hasRounding
|
||||
? `Expected high ${expectedText} maps to the ${bucketLabel} settlement bucket after rounding.`
|
||||
: `Expected high ${expectedText} maps to the ${bucketLabel} bucket.`;
|
||||
return `${mapping} Model probability is still the bucket-distribution probability, not 100% just because the center maps there.`;
|
||||
}
|
||||
const mapping = hasRounding
|
||||
? `预计高点 ${expectedText} 按结算四舍五入映射到 ${bucketLabel} 桶。`
|
||||
: `预计高点 ${expectedText} 对应 ${bucketLabel} 桶。`;
|
||||
return `${mapping} 模型概率仍按温度分布计算,不等于把该桶视为 100%。`;
|
||||
}
|
||||
|
||||
function getMarketSelectedBucket(scan: MarketScan | null | undefined): MarketTopBucket | null {
|
||||
const selected = scan?.temperature_bucket;
|
||||
if (!selected) return null;
|
||||
@@ -366,6 +402,13 @@ export function buildMarketDecisionView({
|
||||
tone: "watch",
|
||||
};
|
||||
}
|
||||
const bucketLabel = getMarketBucketLabel(bucket, tempSymbol);
|
||||
const bucketMappingExplanation = buildBucketMappingExplanation({
|
||||
bucket,
|
||||
expectedHigh,
|
||||
isEn,
|
||||
tempSymbol,
|
||||
});
|
||||
const bucketProbability = getBucketModelProbability(bucket);
|
||||
const scanProbability = normalizeMarketProbability(marketScan.model_probability);
|
||||
const modelProbability = bucketProbability ?? scanProbability;
|
||||
@@ -409,7 +452,7 @@ export function buildMarketDecisionView({
|
||||
: "价格接近天气概率";
|
||||
|
||||
return {
|
||||
bucketLabel: getMarketBucketLabel(bucket, tempSymbol),
|
||||
bucketLabel,
|
||||
confidence: marketScan.confidence || "--",
|
||||
edgeText: formatSignedMarketPercent(edge),
|
||||
impliedText: formatMarketPercent(implied),
|
||||
@@ -426,8 +469,8 @@ export function buildMarketDecisionView({
|
||||
? "Quote is available, but model probability or YES price is incomplete."
|
||||
: "已获取报价,但模型概率或 YES 价格不完整。"
|
||||
: isEn
|
||||
? `Model probability is ${formatMarketPercent(modelProbability)} versus market-implied ${formatMarketPercent(implied)}.`
|
||||
: `模型概率 ${formatMarketPercent(modelProbability)},市场隐含约 ${formatMarketPercent(implied)}。`,
|
||||
? `Model probability is ${formatMarketPercent(modelProbability)} versus market-implied ${formatMarketPercent(implied)}.${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`
|
||||
: `模型概率 ${formatMarketPercent(modelProbability)},市场隐含约 ${formatMarketPercent(implied)}。${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`,
|
||||
status: "ready",
|
||||
title,
|
||||
tone,
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
import { extractStreamingAirportRead } from "./ai-city-stream";
|
||||
import { normalizeCityKey } from "./decision-utils";
|
||||
|
||||
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v3";
|
||||
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v4";
|
||||
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const AI_CITY_FORECAST_MAX_CONCURRENT_STREAMS = 2;
|
||||
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
|
||||
@@ -61,6 +61,17 @@ function buildStorageKey(prefix: string, parts: Array<string | null | undefined>
|
||||
.join(":")}`;
|
||||
}
|
||||
|
||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||
const source = String(
|
||||
detail?.current?.settlement_source ||
|
||||
detail?.settlement_station?.settlement_source ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return source === "hko";
|
||||
}
|
||||
|
||||
function readCachedPayload<T>(key: string, ttlMs: number): T | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
@@ -298,8 +309,8 @@ function requestAiCityForecast({
|
||||
onProgress?.({
|
||||
stage: "queued",
|
||||
message_en:
|
||||
"AI airport-bulletin read is queued behind the cities already streaming...",
|
||||
message_zh: "AI 机场报文解读已排队,正在等待前面的城市完成流式生成…",
|
||||
"AI observation read is queued behind the cities already streaming...",
|
||||
message_zh: "AI 观测解读已排队,正在等待前面的城市完成流式生成…",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -321,8 +332,8 @@ function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boole
|
||||
const rawLength = Number(progress.raw_length);
|
||||
if (Number.isFinite(rawLength) && rawLength > 0) {
|
||||
return isEn
|
||||
? `DeepSeek is streaming the airport-bulletin enhancement... ${Math.round(rawLength)} chars received.`
|
||||
: `DeepSeek 正在流式增强机场报文解读... 已收到 ${Math.round(rawLength)} 字符。`;
|
||||
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
|
||||
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -339,11 +350,13 @@ function buildAiCityFallbackPayload({
|
||||
report: string;
|
||||
}): AiCityForecastPayload {
|
||||
const tempSymbol = detail?.temp_symbol || "°C";
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const currentTemp =
|
||||
detail?.airport_current?.temp ??
|
||||
detail?.airport_primary?.temp ??
|
||||
detail?.current?.temp ??
|
||||
null;
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp
|
||||
: detail?.airport_current?.temp ??
|
||||
detail?.airport_primary?.temp ??
|
||||
detail?.current?.temp) ?? null;
|
||||
const currentText =
|
||||
currentTemp != null && Number.isFinite(Number(currentTemp))
|
||||
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
|
||||
@@ -351,22 +364,28 @@ function buildAiCityFallbackPayload({
|
||||
? "the latest observed temperature"
|
||||
: "最新实测温度";
|
||||
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
|
||||
const rawMetar = String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
|
||||
const rawMetar = isHkoObservation
|
||||
? ""
|
||||
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
|
||||
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
|
||||
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
|
||||
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
|
||||
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
|
||||
|
||||
const finalZh = timeoutLike
|
||||
? "DeepSeek 增强暂未返回;当前先以多模型集中度和最新 METAR 实况快速判断。"
|
||||
: "当前先以多模型集中度和最新 METAR 实况快速判断。";
|
||||
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
|
||||
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
|
||||
const finalEn = timeoutLike
|
||||
? "DeepSeek enhancement is not back yet; use the model cluster and latest METAR as the fast working read."
|
||||
: "Use the model cluster and latest METAR as the fast working read.";
|
||||
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
|
||||
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
|
||||
const metarZh = rawMetar
|
||||
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
|
||||
: `当前可先参考 ${currentText} 与多模型路径,等待下一次机场报文更新。`;
|
||||
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
|
||||
const metarEn = rawMetar
|
||||
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
|
||||
: `Use ${currentText} and the model path for now while waiting for the next airport bulletin.`;
|
||||
const reasonZh = "DEB、多模型集合和最新 METAR 已足够给出当前方向判断;DeepSeek 增强可作为后续补充。";
|
||||
const reasonEn = "DEB, the model cluster and latest METAR are enough for the current directional read; DeepSeek enhancement can be added later.";
|
||||
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
|
||||
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;DeepSeek 增强可作为后续补充。`;
|
||||
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; DeepSeek enhancement can be added later.`;
|
||||
|
||||
return {
|
||||
city_forecast: {
|
||||
@@ -416,14 +435,20 @@ export function useAiCityForecast({
|
||||
const aiForecastKey = useMemo(
|
||||
() => {
|
||||
if (!detail) return "";
|
||||
const airportCurrent = detail.airport_current || detail.current || {};
|
||||
const metarSignature =
|
||||
String(report || "").trim() ||
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const observationSource = isHkoObservation ? "hko" : "metar";
|
||||
const observationCurrent = isHkoObservation
|
||||
? detail.current || {}
|
||||
: detail.airport_current || detail.current || {};
|
||||
const observationSignature =
|
||||
(!isHkoObservation ? String(report || "").trim() : "") ||
|
||||
[
|
||||
airportCurrent.report_time,
|
||||
airportCurrent.obs_time_epoch,
|
||||
airportCurrent.obs_time,
|
||||
airportCurrent.temp,
|
||||
observationSource,
|
||||
observationCurrent.report_time,
|
||||
observationCurrent.obs_time_epoch,
|
||||
observationCurrent.obs_time,
|
||||
observationCurrent.temp,
|
||||
observationCurrent.station_code,
|
||||
]
|
||||
.filter((part) => part != null && part !== "")
|
||||
.join("|");
|
||||
@@ -431,7 +456,7 @@ export function useAiCityForecast({
|
||||
normalizeCityKey(detailCityName),
|
||||
detail.local_date || "",
|
||||
locale,
|
||||
metarSignature,
|
||||
observationSignature,
|
||||
].join(":");
|
||||
},
|
||||
[detail, detailCityName, locale, report],
|
||||
@@ -490,8 +515,8 @@ export function useAiCityForecast({
|
||||
? initialFallback.city_forecast?.metar_read_en
|
||||
: initialFallback.city_forecast?.metar_read_zh) ||
|
||||
(isEn
|
||||
? "Reading the latest airport bulletin with model/METAR fallback ready..."
|
||||
: "已先用最新 METAR 给出兜底解读,正在等待 DeepSeek 补充…"),
|
||||
? "Reading the latest observation with model fallback ready..."
|
||||
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
|
||||
};
|
||||
writeCachedAiForecastState(cacheKey, loadingState);
|
||||
setAiForecast(loadingState);
|
||||
|
||||
Reference in New Issue
Block a user