2026-04-28 14:45:34 +08:00
import type { AiCityStreamProgress } from "@/components/dashboard/scan-terminal/scan-terminal-client" ;
import {
buildStorageKey ,
readCachedPayload ,
removeCachedPayload ,
writeCachedPayload ,
} from "@/components/dashboard/scan-terminal/scan-terminal-cache" ;
import type {
AiCityForecastPayload ,
AiCityForecastState ,
} from "@/components/dashboard/scan-terminal/types" ;
2026-05-17 14:33:23 +08:00
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display" ;
2026-04-28 14:45:34 +08:00
import type { CityDetail } from "@/lib/dashboard-types" ;
import { normalizeCityKey } from "./decision-utils" ;
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6" ;
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000 ;
const aiCityForecastStateCache = new Map <
string ,
{ state : AiCityForecastState ; updatedAt : number }
> ();
function isHkoObservationCity ( detail? : CityDetail | null ) {
const source = String (
detail ? . current ? . settlement_source ||
detail ? . settlement_station ? . settlement_source ||
"" ,
)
. trim ()
. toLowerCase ();
return source === "hko" ;
}
export function buildAiCityForecastKey ({
detail ,
detailCityName ,
locale ,
report ,
} : {
detail : CityDetail | null ;
detailCityName : string ;
locale : string ;
report : string ;
}) {
if ( ! detail ) return "" ;
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 () : "" ) ||
[
observationSource ,
observationCurrent . report_time ,
observationCurrent . obs_time_epoch ,
observationCurrent . obs_time ,
observationCurrent . receipt_time ,
observationCurrent . temp ,
observationCurrent . max_so_far ,
observationCurrent . station_code ,
detail . metar_status ? . stale_for_today ,
detail . metar_status ? . last_observation_time ,
]
. filter (( part ) => part != null && part !== "" )
. join ( "|" );
return [
normalizeCityKey ( detailCityName ),
detail . local_date || "" ,
locale ,
observationSignature ,
]. join ( ":" );
}
export function buildAiCityForecastCacheKey ( aiForecastKey : string ) {
return buildStorageKey ( AI_CITY_FORECAST_CACHE_PREFIX , [ aiForecastKey ]);
}
export function buildAiCityForecastRequestKey ( cacheKey : string , refreshToken : number ) {
return ` ${ cacheKey } : ${ refreshToken > 0 ? `refresh: ${ refreshToken } ` : "normal" } ` ;
}
export function readCachedAiForecastState ( key : string ) {
const cached = aiCityForecastStateCache . get ( key );
if ( ! cached ) return null ;
if ( Date . now () - cached . updatedAt > AI_CITY_FORECAST_CACHE_TTL_MS ) {
aiCityForecastStateCache . delete ( key );
return null ;
}
return cached . state ;
}
export function writeCachedAiForecastState (
key : string ,
state : AiCityForecastState ,
) {
if ( ! key || state . status === "idle" ) return ;
aiCityForecastStateCache . set ( key , {
state ,
updatedAt : Date.now (),
});
}
export function readReadyCachedAiForecastState ( cacheKey : string , refreshToken : number ) {
if ( refreshToken > 0 ) return null ;
const cachedPayload = readCachedPayload < AiCityForecastPayload >(
cacheKey ,
AI_CITY_FORECAST_CACHE_TTL_MS ,
);
if ( cachedPayload ) {
if (
cachedPayload . status === "ready" &&
! cachedPayload . degraded &&
cachedPayload . city_forecast
) {
const readyState : AiCityForecastState = {
payload : cachedPayload ,
status : "ready" ,
};
writeCachedAiForecastState ( cacheKey , readyState );
return readyState ;
}
removeCachedPayload ( cacheKey );
}
const cachedState = readCachedAiForecastState ( cacheKey );
return cachedState ? . status === "ready" ? cachedState : null ;
}
function getAiCityStreamProgressText (
progress : AiCityStreamProgress ,
isEn : boolean ,
) {
const localizedMessage = String (
( isEn ? progress.message_en : progress.message_zh ) ||
( isEn ? progress.final_judgment_en : progress.final_judgment_zh ) ||
( isEn ? progress.metar_read_en : progress.metar_read_zh ) ||
"" ,
). trim ();
if ( localizedMessage ) return localizedMessage ;
const rawLength = Number ( progress . raw_length );
if ( Number . isFinite ( rawLength ) && rawLength > 0 ) {
return isEn
? `DeepSeek is streaming the observation enhancement... ${ Math . round ( rawLength ) } chars received.`
: `DeepSeek 正在流式增强观测解读... 已收到 ${ Math . round ( rawLength ) } 字符。` ;
}
return "" ;
}
2026-05-07 21:04:26 +08:00
function computeFallbackPredictedMax ( detail : CityDetail | null ) : number | null {
if ( ! detail ) return null ;
const multiModel = detail . multi_model ?? {};
const entries = Object . entries ( multiModel ). filter (
([, v ]) => v != null && Number . isFinite ( v ),
) as [ string , number ][];
const values = entries . map (([, v ]) => v );
const debValue = detail . deb ? . prediction ?? null ;
const nonDebValues = entries
. filter (([ name ]) => ! name . toLowerCase (). includes ( "deb" ))
. map (([, v ]) => v );
const sorted = [... nonDebValues ]. sort (( a , b ) => a - b );
const clusterMedian =
sorted . length > 0 ? sorted [ Math . floor ( sorted . length / 2 )] : null ;
let predicted = clusterMedian ;
if ( predicted == null && debValue != null && Number . isFinite ( debValue ))
predicted = debValue ;
if ( predicted == null && values . length > 0 )
predicted = values . reduce (( a , b ) => a + b , 0 ) / values . length ;
if ( predicted == null ) {
const isHkoObservation = isHkoObservationCity ( detail );
2026-05-17 14:33:23 +08:00
const displayAirportPrimary = getDisplayAirportPrimary ( detail );
2026-05-07 21:04:26 +08:00
const currentTemp =
( isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
2026-05-17 14:33:23 +08:00
displayAirportPrimary ? . temp ??
2026-05-07 21:04:26 +08:00
detail ? . current ? . temp ) ?? null ;
predicted = currentTemp != null && Number . isFinite ( currentTemp )
? currentTemp
: null ;
}
return predicted ;
}
2026-04-28 14:45:34 +08:00
export function buildAiCityFallbackPayload ({
detail ,
error ,
isEn ,
report ,
} : {
detail : CityDetail | null ;
error? : unknown ;
isEn : boolean ;
report : string ;
}) : AiCityForecastPayload {
const tempSymbol = detail ? . temp_symbol || "°C" ;
const isHkoObservation = isHkoObservationCity ( detail );
2026-05-17 14:33:23 +08:00
const displayAirportPrimary = getDisplayAirportPrimary ( detail );
2026-04-28 14:45:34 +08:00
const currentTemp =
( isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
2026-05-17 14:33:23 +08:00
displayAirportPrimary ? . temp ??
2026-04-28 14:45:34 +08:00
detail ? . current ? . temp ) ?? null ;
const currentText =
currentTemp != null && Number . isFinite ( Number ( currentTemp ))
? ` ${ Number ( currentTemp ). toFixed ( 1 ) }${ tempSymbol } `
: isEn
? "the latest observed temperature"
: "最新实测温度" ;
const timeoutLike = /timeout|timed out|504|aborted|超时/i . test ( String ( error || "" ));
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 增强暂未返回;当前先以多模型集中度和最新 ${ sourceZh } 快速判断。`
: `当前先以多模型集中度和最新 ${ sourceZh } 快速判断。` ;
const finalEn = timeoutLike
? `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 } 与多模型路径,等待下一次 ${ 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 ${ bulletinEn } .` ;
const reasonZh = `DEB、多模型集合和最新 ${ sourceZh } 已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。` ;
const reasonEn = `DEB, the model cluster and latest ${ sourceEn } are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.` ;
2026-05-07 21:04:26 +08:00
const fallbackPredictedMax = computeFallbackPredictedMax ( detail );
const multiModel = detail ? . multi_model ?? {};
const modelValues = Object . values ( multiModel ). filter (
( v ) : v is number => v != null && Number . isFinite ( v ),
);
2026-04-28 14:45:34 +08:00
return {
city_forecast : {
confidence : "low" ,
final_judgment_en : finalEn ,
final_judgment_zh : finalZh ,
metar_read_en : metarEn ,
metar_read_zh : metarZh ,
model_cluster_note_en : "" ,
model_cluster_note_zh : "" ,
2026-05-07 21:04:26 +08:00
predicted_max : fallbackPredictedMax ,
range_high : modelValues.length > 0 ? Math . max (... modelValues ) : null ,
range_low : modelValues.length > 0 ? Math . min (... modelValues ) : null ,
2026-04-28 14:45:34 +08:00
reasoning_en : reasonEn ,
reasoning_zh : reasonZh ,
risks_en : [],
risks_zh : [],
unit : tempSymbol ,
},
raw_reason : timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback" ,
reason : isEn ? reasonEn : reasonZh ,
reason_en : reasonEn ,
reason_zh : reasonZh ,
status : timeoutLike ? "timeout_fallback" : "fallback" ,
};
}
export function buildAiCityLoadingForecastState ({
cacheKey ,
detail ,
isEn ,
report ,
} : {
cacheKey : string ;
detail : CityDetail | null ;
isEn : boolean ;
report : string ;
}) {
const cachedState = readCachedAiForecastState ( cacheKey );
const initialFallback = buildAiCityFallbackPayload ({ detail , isEn , report });
const loadingState : AiCityForecastState =
cachedState ? . status === "loading"
? cachedState
: {
status : "loading" ,
streamText :
( isEn
? initialFallback.city_forecast?.metar_read_en
: initialFallback.city_forecast?.metar_read_zh ) ||
( isEn
? "Reading the latest observation with model fallback ready..."
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…" ),
};
writeCachedAiForecastState ( cacheKey , loadingState );
return loadingState ;
}
2026-05-07 21:04:26 +08:00
function hasPreviewQuantFields ( progress : AiCityStreamProgress ) {
return (
progress . predicted_max != null ||
progress . range_low != null ||
progress . range_high != null
);
}
function mergePreviewQuantPayload (
current : AiCityForecastState ,
progress : AiCityStreamProgress ,
) : AiCityForecastPayload {
const prev = current . payload ?? {};
const prevCf = prev . city_forecast ?? {};
return {
... prev ,
status : prev.status ?? "ready" ,
city_forecast : {
... prevCf ,
...( progress . predicted_max != null
? { predicted_max : progress.predicted_max }
: {}),
...( progress . range_low != null
? { range_low : progress.range_low }
: {}),
...( progress . range_high != null
? { range_high : progress.range_high }
: {}),
...( progress . confidence != null
? { confidence : progress.confidence }
: {}),
...( progress . unit != null ? { unit : progress.unit } : {}),
...( progress . metar_read_zh != null
? { metar_read_zh : progress.metar_read_zh }
: {}),
...( progress . metar_read_en != null
? { metar_read_en : progress.metar_read_en }
: {}),
...( progress . final_judgment_zh != null
? { final_judgment_zh : progress.final_judgment_zh }
: {}),
...( progress . final_judgment_en != null
? { final_judgment_en : progress.final_judgment_en }
: {}),
...( progress . model_cluster_note_zh != null
? { model_cluster_note_zh : progress.model_cluster_note_zh }
: {}),
...( progress . model_cluster_note_en != null
? { model_cluster_note_en : progress.model_cluster_note_en }
: {}),
},
};
}
2026-04-28 14:45:34 +08:00
export function buildAiCityProgressForecastState ({
cacheKey ,
current ,
isEn ,
progress ,
} : {
cacheKey : string ;
current : AiCityForecastState ;
isEn : boolean ;
progress : AiCityStreamProgress ;
}) {
const progressText = getAiCityStreamProgressText ( progress , isEn );
2026-05-07 21:04:26 +08:00
const hasQuant = hasPreviewQuantFields ( progress );
if ( ! progressText && ! hasQuant ) return null ;
2026-04-28 14:45:34 +08:00
const cachedProgressState = readCachedAiForecastState ( cacheKey );
const nextStreamText =
progress . stage === "calling_ai" && cachedProgressState ? . streamText
? cachedProgressState.streamText
2026-05-07 21:04:26 +08:00
: progressText || cachedProgressState ? . streamText || "" ;
const nextPayload = hasQuant
? mergePreviewQuantPayload (
cachedProgressState ?? current ,
progress ,
)
: cachedProgressState ? . payload ?? current . payload ;
2026-04-28 14:45:34 +08:00
const cachedNextState : AiCityForecastState = {
... cachedProgressState ,
status : "loading" ,
streamText : nextStreamText ,
2026-05-07 21:04:26 +08:00
payload : nextPayload ,
2026-04-28 14:45:34 +08:00
};
writeCachedAiForecastState ( cacheKey , cachedNextState );
return {
... current ,
2026-05-07 21:04:26 +08:00
status : "loading" as const ,
2026-04-28 14:45:34 +08:00
streamText :
progress.stage === "calling_ai" && current . streamText
? current.streamText
2026-05-07 21:04:26 +08:00
: progressText || current . streamText || "" ,
payload : hasQuant
? mergePreviewQuantPayload ( current , progress )
: current . payload ,
2026-04-28 14:45:34 +08:00
} satisfies AiCityForecastState ;
}
export function buildAiCityReadyForecastState ({
cacheKey ,
detail ,
isEn ,
payload ,
report ,
} : {
cacheKey : string ;
detail : CityDetail | null ;
isEn : boolean ;
payload : AiCityForecastPayload ;
report : string ;
}) {
const usablePayload =
payload ? . city_forecast
? payload
: buildAiCityFallbackPayload ({
detail ,
error : payload?.reason || payload ? . raw_reason || payload ? . status ,
isEn ,
report ,
});
if ( usablePayload . status === "ready" && ! usablePayload . degraded ) {
writeCachedPayload ( cacheKey , usablePayload );
}
const readyState : AiCityForecastState = {
payload : usablePayload ,
status : "ready" ,
};
writeCachedAiForecastState ( cacheKey , readyState );
return readyState ;
}
export function buildAiCityErrorForecastState ({
cacheKey ,
detail ,
error ,
isEn ,
report ,
} : {
cacheKey : string ;
detail : CityDetail | null ;
error : unknown ;
isEn : boolean ;
report : string ;
}) {
const fallbackPayload = buildAiCityFallbackPayload ({
detail ,
error ,
isEn ,
report ,
});
const readyState : AiCityForecastState = {
payload : fallbackPayload ,
status : "ready" ,
};
writeCachedAiForecastState ( cacheKey , readyState );
return readyState ;
}