feat: implement AI-driven city weather analysis and market decision dashboard components
This commit is contained in:
@@ -11682,6 +11682,12 @@
|
|||||||
height: 260px;
|
height: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-ai-city-chart canvas) {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-ai-city-chart-legend) {
|
.root :global(.scan-ai-city-chart-legend) {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|||||||
@@ -1,17 +1,86 @@
|
|||||||
import type { ChartConfiguration } from "chart.js";
|
import type { ChartConfiguration } from "chart.js";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import { BarChart3 } from "lucide-react";
|
import { BarChart3 } from "lucide-react";
|
||||||
import type { CityDetail } from "@/lib/dashboard-types";
|
import type { CityDetail } from "@/lib/dashboard-types";
|
||||||
import { useChart } from "@/hooks/useChart";
|
import { useChart } from "@/hooks/useChart";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import { getTemperatureChartData } from "@/lib/dashboard-utils";
|
import { getTemperatureChartData } from "@/lib/dashboard-utils";
|
||||||
|
|
||||||
|
type TemperatureChartData = NonNullable<ReturnType<typeof getTemperatureChartData>>;
|
||||||
|
|
||||||
|
function compactSeries<T extends { time?: string | null; temp?: number | null }>(
|
||||||
|
rows?: T[] | null,
|
||||||
|
) {
|
||||||
|
return (Array.isArray(rows) ? rows : [])
|
||||||
|
.map((row) => `${String(row?.time || "").trim()}=${Number(row?.temp)}`)
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemperatureChartSignature(detail: CityDetail) {
|
||||||
|
const hourly = detail.hourly || {};
|
||||||
|
const mgmHourly = Array.isArray(detail.mgm?.hourly) ? detail.mgm?.hourly || [] : [];
|
||||||
|
const tafMarkers = Array.isArray(detail.taf?.signal?.markers)
|
||||||
|
? detail.taf?.signal?.markers || []
|
||||||
|
: [];
|
||||||
|
return [
|
||||||
|
detail.name,
|
||||||
|
detail.local_date,
|
||||||
|
detail.temp_symbol,
|
||||||
|
(hourly.times || []).join("|"),
|
||||||
|
(hourly.temps || []).map((value) => Number(value)).join("|"),
|
||||||
|
detail.forecast?.today_high ?? "",
|
||||||
|
detail.deb?.prediction ?? "",
|
||||||
|
detail.mgm?.temp ?? "",
|
||||||
|
detail.mgm?.time ?? "",
|
||||||
|
mgmHourly
|
||||||
|
.map((row) => `${String(row?.time || "").trim()}=${Number(row?.temp)}`)
|
||||||
|
.join("|"),
|
||||||
|
compactSeries(detail.metar_today_obs),
|
||||||
|
compactSeries(detail.settlement_today_obs),
|
||||||
|
compactSeries(detail.trend?.recent),
|
||||||
|
detail.current?.temp ?? "",
|
||||||
|
detail.current?.obs_time ?? "",
|
||||||
|
detail.airport_current?.temp ?? "",
|
||||||
|
detail.airport_current?.obs_time ?? "",
|
||||||
|
detail.peak?.first_h ?? "",
|
||||||
|
detail.peak?.last_h ?? "",
|
||||||
|
tafMarkers
|
||||||
|
.map((marker) =>
|
||||||
|
[
|
||||||
|
marker?.marker_type,
|
||||||
|
marker?.label_time,
|
||||||
|
marker?.start_local,
|
||||||
|
marker?.end_local,
|
||||||
|
marker?.summary_zh,
|
||||||
|
marker?.summary_en,
|
||||||
|
]
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.join("="),
|
||||||
|
)
|
||||||
|
.join("|"),
|
||||||
|
].join("::");
|
||||||
|
}
|
||||||
|
|
||||||
export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const chartData = useMemo(
|
const cityKey = `${detail.name || detail.display_name || ""}:${detail.local_date || ""}`;
|
||||||
|
const chartSignature = useMemo(() => buildTemperatureChartSignature(detail), [detail]);
|
||||||
|
const computedChartData = useMemo(
|
||||||
() => getTemperatureChartData(detail, locale),
|
() => getTemperatureChartData(detail, locale),
|
||||||
[detail, locale],
|
[chartSignature, locale],
|
||||||
);
|
);
|
||||||
|
const lastChartDataRef = useRef<{
|
||||||
|
cityKey: string;
|
||||||
|
data: TemperatureChartData;
|
||||||
|
} | null>(null);
|
||||||
|
if (computedChartData) {
|
||||||
|
lastChartDataRef.current = { cityKey, data: computedChartData };
|
||||||
|
}
|
||||||
|
const chartData =
|
||||||
|
computedChartData ||
|
||||||
|
(lastChartDataRef.current?.cityKey === cityKey
|
||||||
|
? lastChartDataRef.current.data
|
||||||
|
: null);
|
||||||
const forecastLabel = chartData?.datasets.hasMgmHourly
|
const forecastLabel = chartData?.datasets.hasMgmHourly
|
||||||
? locale === "en-US"
|
? locale === "en-US"
|
||||||
? "MGM forecast"
|
? "MGM forecast"
|
||||||
@@ -62,6 +131,7 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
|||||||
labels: chartData.times,
|
labels: chartData.times,
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
animation: false,
|
||||||
interaction: { intersect: false, mode: "index" },
|
interaction: { intersect: false, mode: "index" },
|
||||||
layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } },
|
layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } },
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
@@ -102,6 +172,12 @@ export function AiCityTemperatureChart({ detail }: { detail: CityDetail }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
transitions: {
|
||||||
|
active: { animation: { duration: 0 } },
|
||||||
|
hide: { animation: { duration: 0 } },
|
||||||
|
resize: { animation: { duration: 0 } },
|
||||||
|
show: { animation: { duration: 0 } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
type: "line",
|
type: "line",
|
||||||
} satisfies ChartConfiguration<"line">;
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import { formatTemperatureValue, getModelView, getTodayPaceView } from "@/lib/dashboard-utils";
|
import { formatTemperatureValue, getModelView, getTodayPaceView } from "@/lib/dashboard-utils";
|
||||||
|
|
||||||
|
function toFiniteDecisionNumber(value: unknown) {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
function AiPinnedCityCard({
|
function AiPinnedCityCard({
|
||||||
item,
|
item,
|
||||||
detail,
|
detail,
|
||||||
@@ -86,6 +92,7 @@ function AiPinnedCityCard({
|
|||||||
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
||||||
detail,
|
detail,
|
||||||
detailCityName,
|
detailCityName,
|
||||||
|
enabled: Boolean(detail && !collapsed),
|
||||||
isEn,
|
isEn,
|
||||||
locale,
|
locale,
|
||||||
report,
|
report,
|
||||||
@@ -93,6 +100,7 @@ function AiPinnedCityCard({
|
|||||||
const { marketScan, marketStatus } = useCityMarketScan({
|
const { marketScan, marketStatus } = useCityMarketScan({
|
||||||
detail,
|
detail,
|
||||||
detailCityName,
|
detailCityName,
|
||||||
|
enabled: Boolean(detail && !collapsed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||||
@@ -125,8 +133,8 @@ function AiPinnedCityCard({
|
|||||||
: isEn
|
: isEn
|
||||||
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
|
? "Model support is unavailable, so this city must rely on DEB path and METAR observations."
|
||||||
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
|
: "暂无可用多模型支撑,需要主要参考 DEB 路径和 METAR 实测。";
|
||||||
const aiPredictedMax = Number(aiCityForecast?.predicted_max);
|
const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max);
|
||||||
const decisionExpectedHighNumber = Number.isFinite(aiPredictedMax)
|
const decisionExpectedHighNumber = aiPredictedMax != null
|
||||||
? aiPredictedMax
|
? aiPredictedMax
|
||||||
: paceView?.paceAdjustedHigh != null
|
: paceView?.paceAdjustedHigh != null
|
||||||
? paceView.paceAdjustedHigh
|
? paceView.paceAdjustedHigh
|
||||||
|
|||||||
@@ -5,6 +5,38 @@ import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
|||||||
import { formatTemperatureValue } from "@/lib/dashboard-utils";
|
import { formatTemperatureValue } from "@/lib/dashboard-utils";
|
||||||
import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils";
|
import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||||
|
|
||||||
|
function normalizeCalendarCityKey(value?: string | null) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_-]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarCardKey(row: ScanOpportunityRow) {
|
||||||
|
const city =
|
||||||
|
normalizeCalendarCityKey(row.city) ||
|
||||||
|
normalizeCalendarCityKey(row.city_display_name) ||
|
||||||
|
normalizeCalendarCityKey(row.display_name);
|
||||||
|
const date = String(row.selected_date || row.local_date || "").trim();
|
||||||
|
return `${city || row.id}:${date || "date-unknown"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarRowScore(row: ScanOpportunityRow) {
|
||||||
|
return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeCalendarRows(rows: ScanOpportunityRow[]) {
|
||||||
|
const bestByCard = new Map<string, ScanOpportunityRow>();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const key = getCalendarCardKey(row);
|
||||||
|
const current = bestByCard.get(key);
|
||||||
|
if (!current || getCalendarRowScore(row) > getCalendarRowScore(current)) {
|
||||||
|
bestByCard.set(key, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...bestByCard.values()];
|
||||||
|
}
|
||||||
|
|
||||||
export function CalendarView({
|
export function CalendarView({
|
||||||
rows,
|
rows,
|
||||||
locale,
|
locale,
|
||||||
@@ -26,7 +58,7 @@ export function CalendarView({
|
|||||||
items: Array<{ row: ScanOpportunityRow; meta: ReturnType<typeof getPeakCountdownMeta> }>;
|
items: Array<{ row: ScanOpportunityRow; meta: ReturnType<typeof getPeakCountdownMeta> }>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
rows.forEach((row) => {
|
dedupeCalendarRows(rows).forEach((row) => {
|
||||||
const meta = getPeakCountdownMeta(row, locale);
|
const meta = getPeakCountdownMeta(row, locale);
|
||||||
const current = byPhase.get(meta.key) || {
|
const current = byPhase.get(meta.key) || {
|
||||||
label: meta.groupLabel,
|
label: meta.groupLabel,
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ function normalizeQuotePrice(value: unknown) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toFiniteMarketNumber(value: unknown) {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatMarketPercent(value: number | null, digits = 1) {
|
export function formatMarketPercent(value: number | null, digits = 1) {
|
||||||
if (value == null || !Number.isFinite(value)) return "--";
|
if (value == null || !Number.isFinite(value)) return "--";
|
||||||
return `${(value * 100).toFixed(digits)}%`;
|
return `${(value * 100).toFixed(digits)}%`;
|
||||||
@@ -76,9 +82,8 @@ export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol
|
|||||||
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) {
|
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) {
|
||||||
return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F");
|
return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F");
|
||||||
}
|
}
|
||||||
const value = bucket?.temp ?? bucket?.value ?? bucket?.lower ?? null;
|
const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower);
|
||||||
const numeric = Number(value);
|
if (numeric != null) {
|
||||||
if (Number.isFinite(numeric)) {
|
|
||||||
const unit = bucket?.unit
|
const unit = bucket?.unit
|
||||||
? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
|
? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
|
||||||
: tempSymbol;
|
: tempSymbol;
|
||||||
@@ -88,8 +93,7 @@ export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBucketAnchor(bucket: MarketTopBucket) {
|
function getBucketAnchor(bucket: MarketTopBucket) {
|
||||||
const anchor = Number(bucket.temp ?? bucket.value ?? bucket.lower);
|
return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower);
|
||||||
return Number.isFinite(anchor) ? anchor : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBucketModelProbability(bucket?: MarketTopBucket | null) {
|
function getBucketModelProbability(bucket?: MarketTopBucket | null) {
|
||||||
@@ -157,7 +161,7 @@ export function pickMarketBucketForWeatherCenter(
|
|||||||
return Math.abs(anchor - comparable) <= maxReasonableDelta;
|
return Math.abs(anchor - comparable) <= maxReasonableDelta;
|
||||||
};
|
};
|
||||||
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
|
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
|
||||||
return selectedBucket;
|
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nearest: MarketTopBucket | null = null;
|
let nearest: MarketTopBucket | null = null;
|
||||||
@@ -364,22 +368,22 @@ export function buildWeatherDecisionView({
|
|||||||
peakWindow: string;
|
peakWindow: string;
|
||||||
tempSymbol: string;
|
tempSymbol: string;
|
||||||
}): WeatherDecisionView {
|
}): WeatherDecisionView {
|
||||||
const aiPredicted = Number(aiCityForecast?.predicted_max);
|
const aiPredictedMax = toFiniteMarketNumber(aiCityForecast?.predicted_max);
|
||||||
const center = Number.isFinite(aiPredicted)
|
const center = aiPredictedMax != null
|
||||||
? aiPredicted
|
? aiPredictedMax
|
||||||
: paceView?.paceAdjustedHigh != null
|
: paceView?.paceAdjustedHigh != null
|
||||||
? paceView.paceAdjustedHigh
|
? paceView.paceAdjustedHigh
|
||||||
: deb;
|
: deb;
|
||||||
const aiLow = Number(aiCityForecast?.range_low);
|
const aiLow = toFiniteMarketNumber(aiCityForecast?.range_low);
|
||||||
const aiHigh = Number(aiCityForecast?.range_high);
|
const aiHigh = toFiniteMarketNumber(aiCityForecast?.range_high);
|
||||||
const low = Number.isFinite(aiLow)
|
const low = aiLow != null
|
||||||
? aiLow
|
? aiLow
|
||||||
: modelMin != null
|
: modelMin != null
|
||||||
? modelMin
|
? modelMin
|
||||||
: center != null
|
: center != null
|
||||||
? center - 1
|
? center - 1
|
||||||
: null;
|
: null;
|
||||||
const high = Number.isFinite(aiHigh)
|
const high = aiHigh != null
|
||||||
? aiHigh
|
? aiHigh
|
||||||
: modelMax != null
|
: modelMax != null
|
||||||
? modelMax
|
? modelMax
|
||||||
|
|||||||
@@ -14,15 +14,115 @@ import { useDashboardStore } from "@/hooks/useDashboardStore";
|
|||||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||||
import { normalizeCityKey } from "./decision-utils";
|
import { normalizeCityKey } from "./decision-utils";
|
||||||
|
|
||||||
|
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v1";
|
||||||
|
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
|
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v1";
|
||||||
|
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function getStorage() {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStorageKey(prefix: string, parts: Array<string | null | undefined>) {
|
||||||
|
return `${prefix}:${parts
|
||||||
|
.map((part) => encodeURIComponent(String(part || "").trim()))
|
||||||
|
.join(":")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedPayload<T>(key: string, ttlMs: number): T | null {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T };
|
||||||
|
if (!parsed?.payload) return null;
|
||||||
|
if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCachedPayload<T>(key: string, payload: T) {
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
try {
|
||||||
|
storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload }));
|
||||||
|
} catch {
|
||||||
|
// Ignore quota/privacy-mode failures; network fallbacks still work.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPartialAiStreamPayload({
|
||||||
|
fallbackText,
|
||||||
|
isEn,
|
||||||
|
tempSymbol,
|
||||||
|
}: {
|
||||||
|
fallbackText?: string | null;
|
||||||
|
isEn: boolean;
|
||||||
|
tempSymbol?: string | null;
|
||||||
|
}): AiCityForecastPayload {
|
||||||
|
const preservedText =
|
||||||
|
String(fallbackText || "").trim() ||
|
||||||
|
(isEn
|
||||||
|
? "The AI airport read stream was interrupted after partial output."
|
||||||
|
: "AI 机场报文解读已输出部分内容,但最终载荷未返回。");
|
||||||
|
const retryHint = isEn
|
||||||
|
? "The streaming connection ended before the final structured payload. The partial airport read above is preserved; refresh once if you need the full JSON-backed conclusion."
|
||||||
|
: "流式连接在最终结构化载荷返回前结束。上方已保留已输出的机场报文解读;如需完整 JSON 结论可刷新一次。";
|
||||||
|
|
||||||
|
return {
|
||||||
|
city_forecast: {
|
||||||
|
confidence: "low",
|
||||||
|
final_judgment_en: isEn
|
||||||
|
? preservedText
|
||||||
|
: "Partial AI airport read was preserved after the stream ended early.",
|
||||||
|
final_judgment_zh: isEn
|
||||||
|
? "AI 机场报文解读已保留部分输出,但流式连接提前结束。"
|
||||||
|
: preservedText,
|
||||||
|
metar_read_en: isEn ? preservedText : "",
|
||||||
|
metar_read_zh: isEn ? "" : preservedText,
|
||||||
|
model_cluster_note_en: "",
|
||||||
|
model_cluster_note_zh: "",
|
||||||
|
predicted_max: null,
|
||||||
|
range_high: null,
|
||||||
|
range_low: null,
|
||||||
|
reasoning_en: retryHint,
|
||||||
|
reasoning_zh: retryHint,
|
||||||
|
risks_en: isEn ? [retryHint] : [],
|
||||||
|
risks_zh: isEn ? [] : [retryHint],
|
||||||
|
unit: tempSymbol || "°C",
|
||||||
|
},
|
||||||
|
raw_reason: "partial_ai_stream_without_final_payload",
|
||||||
|
reason: retryHint,
|
||||||
|
reason_en: isEn
|
||||||
|
? retryHint
|
||||||
|
: "AI stream ended before the final payload; partial text was preserved.",
|
||||||
|
reason_zh: isEn ? "AI 流在最终载荷前结束;已保留部分文本。" : retryHint,
|
||||||
|
status: "partial_stream",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useAiCityForecast({
|
export function useAiCityForecast({
|
||||||
detail,
|
detail,
|
||||||
detailCityName,
|
detailCityName,
|
||||||
isEn,
|
isEn,
|
||||||
locale,
|
locale,
|
||||||
report,
|
report,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
detail: CityDetail | null;
|
detail: CityDetail | null;
|
||||||
detailCityName: string;
|
detailCityName: string;
|
||||||
|
enabled?: boolean;
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
report: string;
|
report: string;
|
||||||
@@ -34,18 +134,34 @@ export function useAiCityForecast({
|
|||||||
const aiForecastKey = useMemo(
|
const aiForecastKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detail
|
detail
|
||||||
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${report || ""}`
|
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${locale}:${report || ""}`
|
||||||
: "",
|
: "",
|
||||||
[detail, detailCityName, report],
|
[detail, detailCityName, locale, report],
|
||||||
);
|
);
|
||||||
|
const aiTempSymbol = detail?.temp_symbol || "°C";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!aiForecastKey) {
|
if (!enabled || !aiForecastKey) {
|
||||||
setAiForecast({ status: "idle" });
|
setAiForecast({ status: "idle" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
|
||||||
|
const cachedPayload =
|
||||||
|
aiRefreshToken <= 0
|
||||||
|
? readCachedPayload<AiCityForecastPayload>(
|
||||||
|
cacheKey,
|
||||||
|
AI_CITY_FORECAST_CACHE_TTL_MS,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (cachedPayload) {
|
||||||
|
setAiForecast({ payload: cachedPayload, status: "ready" });
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
setAiForecast({ status: "loading", streamText: null, streamRaw: "" });
|
setAiForecast({ status: "loading", streamText: null, streamRaw: "" });
|
||||||
enqueueAiCityFetch(
|
enqueueAiCityFetch(
|
||||||
() =>
|
() =>
|
||||||
@@ -196,43 +312,14 @@ export function useAiCityForecast({
|
|||||||
if (!finalPayload) {
|
if (!finalPayload) {
|
||||||
const fallbackText =
|
const fallbackText =
|
||||||
extractStreamingAirportRead(rawStream, locale) ||
|
extractStreamingAirportRead(rawStream, locale) ||
|
||||||
latestReadableText ||
|
latestReadableText;
|
||||||
(isEn
|
const partialPayload = buildPartialAiStreamPayload({
|
||||||
? "The AI airport read stream was interrupted after partial output."
|
fallbackText,
|
||||||
: "AI 机场报文解读已输出部分内容,但最终载荷未返回。");
|
isEn,
|
||||||
const retryHint = isEn
|
tempSymbol: aiTempSymbol,
|
||||||
? "The streaming connection ended before the final structured payload. The partial airport read above is preserved; refresh once if you need the full JSON-backed conclusion."
|
});
|
||||||
: "流式连接在最终结构化载荷返回前结束。上方已保留已输出的机场报文解读;如需完整 JSON 结论可刷新一次。";
|
writeCachedPayload(cacheKey, partialPayload);
|
||||||
return {
|
return partialPayload;
|
||||||
city_forecast: {
|
|
||||||
confidence: "low",
|
|
||||||
final_judgment_en: isEn
|
|
||||||
? fallbackText
|
|
||||||
: "Partial AI airport read was preserved after the stream ended early.",
|
|
||||||
final_judgment_zh: isEn
|
|
||||||
? "AI 机场报文解读已保留部分输出,但流式连接提前结束。"
|
|
||||||
: fallbackText,
|
|
||||||
metar_read_en: isEn ? fallbackText : "",
|
|
||||||
metar_read_zh: isEn ? "" : fallbackText,
|
|
||||||
model_cluster_note_en: "",
|
|
||||||
model_cluster_note_zh: "",
|
|
||||||
predicted_max: null,
|
|
||||||
range_high: null,
|
|
||||||
range_low: null,
|
|
||||||
reasoning_en: retryHint,
|
|
||||||
reasoning_zh: retryHint,
|
|
||||||
risks_en: isEn ? [retryHint] : [],
|
|
||||||
risks_zh: isEn ? [] : [retryHint],
|
|
||||||
unit: detail?.temp_symbol || "°C",
|
|
||||||
},
|
|
||||||
raw_reason: "AI stream ended before final payload",
|
|
||||||
reason: retryHint,
|
|
||||||
reason_en: isEn
|
|
||||||
? retryHint
|
|
||||||
: "AI stream ended before the final payload; partial text was preserved.",
|
|
||||||
reason_zh: isEn ? "AI 流在最终载荷前结束;已保留部分文本。" : retryHint,
|
|
||||||
status: "partial_stream",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return finalPayload;
|
return finalPayload;
|
||||||
}),
|
}),
|
||||||
@@ -270,12 +357,29 @@ export function useAiCityForecast({
|
|||||||
)
|
)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
writeCachedPayload(cacheKey, payload);
|
||||||
setAiForecast({ payload, status: "ready" });
|
setAiForecast({ payload, status: "ready" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
const message = String(error);
|
||||||
|
if (message.includes("AI stream ended before final payload")) {
|
||||||
|
setAiForecast((current) => {
|
||||||
|
const partialPayload = buildPartialAiStreamPayload({
|
||||||
|
fallbackText: current.streamText,
|
||||||
|
isEn,
|
||||||
|
tempSymbol: aiTempSymbol,
|
||||||
|
});
|
||||||
|
writeCachedPayload(cacheKey, partialPayload);
|
||||||
|
return {
|
||||||
|
payload: partialPayload,
|
||||||
|
status: "ready",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAiForecast({ error: String(error), status: "failed" });
|
setAiForecast({ error: String(error), status: "failed" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -283,7 +387,7 @@ export function useAiCityForecast({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [aiForecastKey, aiRefreshToken, detailCityName, isEn, locale]);
|
}, [aiForecastKey, aiRefreshToken, aiTempSymbol, detailCityName, enabled, isEn, locale]);
|
||||||
|
|
||||||
const refreshAiForecast = useCallback(() => {
|
const refreshAiForecast = useCallback(() => {
|
||||||
setAiRefreshToken((current) => current + 1);
|
setAiRefreshToken((current) => current + 1);
|
||||||
@@ -295,9 +399,11 @@ export function useAiCityForecast({
|
|||||||
export function useCityMarketScan({
|
export function useCityMarketScan({
|
||||||
detail,
|
detail,
|
||||||
detailCityName,
|
detailCityName,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
detail: CityDetail | null;
|
detail: CityDetail | null;
|
||||||
detailCityName: string;
|
detailCityName: string;
|
||||||
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ensureCityMarketScan = useDashboardStore().ensureCityMarketScan;
|
const ensureCityMarketScan = useDashboardStore().ensureCityMarketScan;
|
||||||
const [marketScan, setMarketScan] = useState<MarketScan | null>(
|
const [marketScan, setMarketScan] = useState<MarketScan | null>(
|
||||||
@@ -313,10 +419,46 @@ export function useCityMarketScan({
|
|||||||
setMarketStatus("idle");
|
setMarketStatus("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
|
||||||
|
normalizeCityKey(detailCityName),
|
||||||
|
detail.local_date || "",
|
||||||
|
"lite",
|
||||||
|
]);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (detail.market_scan) {
|
if (detail.market_scan) {
|
||||||
setMarketScan(detail.market_scan);
|
setMarketScan(detail.market_scan);
|
||||||
setMarketStatus("ready");
|
setMarketStatus("ready");
|
||||||
|
writeCachedPayload(cacheKey, detail.market_scan);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!enabled) {
|
||||||
|
const cached = readCachedPayload<MarketScan>(
|
||||||
|
cacheKey,
|
||||||
|
CITY_MARKET_SCAN_CACHE_TTL_MS,
|
||||||
|
);
|
||||||
|
if (cached) {
|
||||||
|
setMarketScan(cached);
|
||||||
|
setMarketStatus("ready");
|
||||||
|
} else {
|
||||||
|
setMarketScan(null);
|
||||||
|
setMarketStatus("idle");
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cached = readCachedPayload<MarketScan>(
|
||||||
|
cacheKey,
|
||||||
|
CITY_MARKET_SCAN_CACHE_TTL_MS,
|
||||||
|
);
|
||||||
|
if (cached) {
|
||||||
|
setMarketScan(cached);
|
||||||
|
setMarketStatus("ready");
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
setMarketStatus("loading");
|
setMarketStatus("loading");
|
||||||
}
|
}
|
||||||
@@ -326,6 +468,9 @@ export function useCityMarketScan({
|
|||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (payload) {
|
||||||
|
writeCachedPayload(cacheKey, payload);
|
||||||
|
}
|
||||||
setMarketScan(payload || detail.market_scan || null);
|
setMarketScan(payload || detail.market_scan || null);
|
||||||
setMarketStatus("ready");
|
setMarketStatus("ready");
|
||||||
})
|
})
|
||||||
@@ -337,7 +482,7 @@ export function useCityMarketScan({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [detail, detailCityName, ensureCityMarketScan]);
|
}, [detail, detailCityName, enabled, ensureCityMarketScan]);
|
||||||
|
|
||||||
return { marketScan, marketStatus };
|
return { marketScan, marketStatus };
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -2872,16 +2872,31 @@ def _build_city_market_scan_payload(
|
|||||||
primary_bucket = None
|
primary_bucket = None
|
||||||
if isinstance(distribution, list) and distribution:
|
if isinstance(distribution, list) and distribution:
|
||||||
ranked_buckets = []
|
ranked_buckets = []
|
||||||
|
temp_symbol_upper = str(temp_symbol or "").upper()
|
||||||
|
max_primary_bucket_delta = 16.0 if "F" in temp_symbol_upper else 8.0
|
||||||
for idx, row in enumerate(distribution_all):
|
for idx, row in enumerate(distribution_all):
|
||||||
if not isinstance(row, dict):
|
if not isinstance(row, dict):
|
||||||
continue
|
continue
|
||||||
|
bucket_value = _sf(
|
||||||
|
row.get("temp")
|
||||||
|
if row.get("temp") is not None
|
||||||
|
else row.get("value")
|
||||||
|
if row.get("value") is not None
|
||||||
|
else row.get("lower")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
anchor_temp is not None
|
||||||
|
and bucket_value is not None
|
||||||
|
and abs(float(bucket_value) - float(anchor_temp)) > max_primary_bucket_delta
|
||||||
|
):
|
||||||
|
continue
|
||||||
bucket_prob = _sf(row.get("probability"))
|
bucket_prob = _sf(row.get("probability"))
|
||||||
prob_rank = bucket_prob if bucket_prob is not None else -1.0
|
prob_rank = bucket_prob if bucket_prob is not None else -1.0
|
||||||
ranked_buckets.append((-prob_rank, idx, row))
|
ranked_buckets.append((-prob_rank, idx, row))
|
||||||
if ranked_buckets:
|
if ranked_buckets:
|
||||||
ranked_buckets.sort(key=lambda x: (x[0], x[1]))
|
ranked_buckets.sort(key=lambda x: (x[0], x[1]))
|
||||||
primary_bucket = ranked_buckets[0][2]
|
primary_bucket = ranked_buckets[0][2]
|
||||||
else:
|
elif anchor_temp is None:
|
||||||
primary_bucket = distribution[0]
|
primary_bucket = distribution[0]
|
||||||
|
|
||||||
model_probability = None
|
model_probability = None
|
||||||
|
|||||||
Reference in New Issue
Block a user