Improve scan decision card hydration
This commit is contained in:
@@ -38,6 +38,14 @@ function toFiniteDecisionNumber(value: unknown) {
|
|||||||
return Number.isFinite(numeric) ? numeric : null;
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRowModelEntries(row: ScanOpportunityRow | null) {
|
||||||
|
const sources = row?.model_cluster_sources;
|
||||||
|
if (!sources || typeof sources !== "object") return [];
|
||||||
|
return Object.entries(sources)
|
||||||
|
.map(([name, value]) => [name, Number(value)] as const)
|
||||||
|
.filter(([, value]) => Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
function parseEpochMs(value: unknown) {
|
function parseEpochMs(value: unknown) {
|
||||||
if (value == null || value === "") return null;
|
if (value == null || value === "") return null;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -228,11 +236,12 @@ export function AiPinnedCityCard({
|
|||||||
item.cityName;
|
item.cityName;
|
||||||
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
|
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
|
||||||
const modelView = detail ? getModelView(detail, detail.local_date) : null;
|
const modelView = detail ? getModelView(detail, detail.local_date) : null;
|
||||||
const modelEntries = modelView
|
const detailModelEntries = modelView
|
||||||
? Object.entries(modelView.models || {})
|
? Object.entries(modelView.models || {})
|
||||||
.map(([name, value]) => [name, Number(value)] as const)
|
.map(([name, value]) => [name, Number(value)] as const)
|
||||||
.filter(([, value]) => Number.isFinite(value))
|
.filter(([, value]) => Number.isFinite(value))
|
||||||
: [];
|
: [];
|
||||||
|
const modelEntries = detailModelEntries.length ? detailModelEntries : getRowModelEntries(row);
|
||||||
const modelValues = modelEntries.map(([, value]) => value);
|
const modelValues = modelEntries.map(([, value]) => value);
|
||||||
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
|
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
|
||||||
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
|
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
|
||||||
@@ -325,10 +334,24 @@ export function AiPinnedCityCard({
|
|||||||
: isEn
|
: isEn
|
||||||
? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.`
|
? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.`
|
||||||
: `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`;
|
: `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`;
|
||||||
const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max);
|
const rowAiPredictedMax =
|
||||||
const aiRangeLow = toFiniteDecisionNumber(aiCityForecast?.range_low);
|
toFiniteDecisionNumber(row?.ai_predicted_max) ??
|
||||||
const aiRangeHigh = toFiniteDecisionNumber(aiCityForecast?.range_high);
|
toFiniteDecisionNumber(row?.ai_predicted_high) ??
|
||||||
const aiConfidence = String(aiCityForecast?.confidence || "").trim() || null;
|
toFiniteDecisionNumber(row?.cluster_median) ??
|
||||||
|
debNumber;
|
||||||
|
const aiPredictedMax =
|
||||||
|
toFiniteDecisionNumber(aiCityForecast?.predicted_max) ?? rowAiPredictedMax;
|
||||||
|
const aiRangeLow =
|
||||||
|
toFiniteDecisionNumber(aiCityForecast?.range_low) ??
|
||||||
|
toFiniteDecisionNumber(row?.ai_predicted_low) ??
|
||||||
|
modelMin;
|
||||||
|
const aiRangeHigh =
|
||||||
|
toFiniteDecisionNumber(aiCityForecast?.range_high) ??
|
||||||
|
toFiniteDecisionNumber(row?.ai_predicted_high) ??
|
||||||
|
modelMax;
|
||||||
|
const aiConfidence =
|
||||||
|
String(aiCityForecast?.confidence || row?.ai_forecast_confidence || "").trim() ||
|
||||||
|
(rowAiPredictedMax != null ? (isEn ? "fast" : "快速") : null);
|
||||||
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
|
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
|
||||||
aiPredictedMax,
|
aiPredictedMax,
|
||||||
currentTemp: currentTempNumber,
|
currentTemp: currentTempNumber,
|
||||||
|
|||||||
+14
@@ -123,6 +123,20 @@ export function runTests() {
|
|||||||
assert.equal(errorState.payload?.status, "timeout_fallback");
|
assert.equal(errorState.payload?.status, "timeout_fallback");
|
||||||
assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/);
|
assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/);
|
||||||
|
|
||||||
|
const modelFallbackState = buildAiCityErrorForecastState({
|
||||||
|
cacheKey: `${cacheKey}:models`,
|
||||||
|
detail: cityDetail({
|
||||||
|
deb: { prediction: 29 },
|
||||||
|
multi_model: { ECMWF: 30, GFS: 32, ICON: 31 },
|
||||||
|
} as unknown as Partial<CityDetail>),
|
||||||
|
error: new Error("timeout"),
|
||||||
|
isEn: false,
|
||||||
|
report: "",
|
||||||
|
});
|
||||||
|
assert.equal(modelFallbackState.payload?.city_forecast?.predicted_max, 31);
|
||||||
|
assert.equal(modelFallbackState.payload?.city_forecast?.range_low, 30);
|
||||||
|
assert.equal(modelFallbackState.payload?.city_forecast?.range_high, 32);
|
||||||
|
|
||||||
const hkoState = buildAiCityErrorForecastState({
|
const hkoState = buildAiCityErrorForecastState({
|
||||||
cacheKey: `${cacheKey}:hko`,
|
cacheKey: `${cacheKey}:hko`,
|
||||||
detail: cityDetail({
|
detail: cityDetail({
|
||||||
|
|||||||
+23
@@ -14,7 +14,17 @@ export function runTests() {
|
|||||||
"scan-terminal",
|
"scan-terminal",
|
||||||
"use-ai-pinned-city-workspace.ts",
|
"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 source = fs.readFileSync(workspacePath, "utf8");
|
||||||
|
const storeSource = fs.readFileSync(storePath, "utf8");
|
||||||
|
const cardSource = fs.readFileSync(cardPath, "utf8");
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
!source.includes("waitForDeepAnalysisQueue"),
|
!source.includes("waitForDeepAnalysisQueue"),
|
||||||
@@ -24,4 +34,17 @@ export function runTests() {
|
|||||||
/store\.ensureCityDetail\(\s*nextCity,\s*false,\s*"full",?\s*\)/.test(source),
|
/store\.ensureCityDetail\(\s*nextCity,\s*false,\s*"full",?\s*\)/.test(source),
|
||||||
"automatic deep analysis hydration should use cache-friendly full detail requests",
|
"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("row?.ai_predicted_max") &&
|
||||||
|
cardSource.includes("row?.cluster_median") &&
|
||||||
|
cardSource.includes("detailModelEntries.length ? detailModelEntries : getRowModelEntries(row)"),
|
||||||
|
"decision card should render model support and AI predicted max from the row before full detail/AI stream arrives",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -810,9 +810,61 @@ export function DashboardStoreProvider({
|
|||||||
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
|
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
|
||||||
// Pre-populate cache from scan terminal row so detail panel shows data immediately
|
// Pre-populate cache from scan terminal row so detail panel shows data immediately
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const currentTemp = Number(row.current_temp ?? row.current_max_so_far);
|
||||||
|
const debPrediction = Number(row.deb_prediction);
|
||||||
|
const rawModelSources =
|
||||||
|
row.model_cluster_sources && typeof row.model_cluster_sources === "object"
|
||||||
|
? (row.model_cluster_sources as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const multiModel = Object.fromEntries(
|
||||||
|
Object.entries(rawModelSources)
|
||||||
|
.map(([name, value]) => [name, Number(value)] as const)
|
||||||
|
.filter(([, value]) => Number.isFinite(value)),
|
||||||
|
);
|
||||||
setCityDetailsByName((current) => ({
|
setCityDetailsByName((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[cityName]: { display_name: cityName, local_date: "", local_time: "", deb: {}, probabilities: {}, multi_model: {}, } as CityDetail,
|
[cityName]: {
|
||||||
|
name: String(row.city || cityName),
|
||||||
|
display_name: String(row.city_display_name || row.display_name || cityName),
|
||||||
|
detail_depth: "panel",
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
local_date: String(row.local_date || row.selected_date || ""),
|
||||||
|
local_time: String(row.local_time || ""),
|
||||||
|
temp_symbol: String(row.temp_symbol || "°C"),
|
||||||
|
current: {
|
||||||
|
temp: Number.isFinite(currentTemp) ? currentTemp : null,
|
||||||
|
max_so_far: Number.isFinite(Number(row.current_max_so_far))
|
||||||
|
? Number(row.current_max_so_far)
|
||||||
|
: Number.isFinite(currentTemp)
|
||||||
|
? currentTemp
|
||||||
|
: null,
|
||||||
|
max_temp_time: null,
|
||||||
|
wu_settlement: null,
|
||||||
|
station_code: null,
|
||||||
|
station_name: String(row.airport || ""),
|
||||||
|
obs_time: String((row.metar_context as { last_time?: string } | null)?.last_time || ""),
|
||||||
|
obs_age_min: null,
|
||||||
|
wind_speed_kt: null,
|
||||||
|
wind_dir: null,
|
||||||
|
humidity: null,
|
||||||
|
cloud_desc: null,
|
||||||
|
clouds_raw: [],
|
||||||
|
visibility_mi: null,
|
||||||
|
wx_desc: null,
|
||||||
|
},
|
||||||
|
deb: {
|
||||||
|
prediction: Number.isFinite(debPrediction) ? debPrediction : null,
|
||||||
|
},
|
||||||
|
forecast: { today_high: null, daily: [] },
|
||||||
|
hourly: { times: [], temps: [] },
|
||||||
|
multi_model: multiModel,
|
||||||
|
probabilities: {},
|
||||||
|
risk: {
|
||||||
|
level: String(row.risk_level || "medium"),
|
||||||
|
airport: String(row.airport || ""),
|
||||||
|
},
|
||||||
|
} as CityDetail,
|
||||||
}));
|
}));
|
||||||
setCityDetailMetaByName((current) => ({
|
setCityDetailMetaByName((current) => ({
|
||||||
...current,
|
...current,
|
||||||
|
|||||||
@@ -1083,7 +1083,7 @@ def _process_airport_city(
|
|||||||
last_city: dict,
|
last_city: dict,
|
||||||
chat_ids: List[str],
|
chat_ids: List[str],
|
||||||
bot: Any,
|
bot: Any,
|
||||||
) -> tuple | None:
|
) -> Optional[Tuple[str, dict]]:
|
||||||
"""Process one airport city and return (city, new_state_entry) or None.
|
"""Process one airport city and return (city, new_state_entry) or None.
|
||||||
|
|
||||||
This is the per-city unit used by the concurrent thread pool in
|
This is the per-city unit used by the concurrent thread pool in
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ def test_high_freq_airport_push_forces_analysis_refresh(monkeypatch):
|
|||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def fake_analyze(city, force_refresh=False, **_kwargs):
|
def fake_analyze(city, force_refresh=False, force_refresh_observations_only=False, **_kwargs):
|
||||||
calls.append((city, force_refresh))
|
calls.append((city, force_refresh, force_refresh_observations_only))
|
||||||
return {
|
return {
|
||||||
"local_time": "12:00",
|
"local_time": "12:00",
|
||||||
"current": {"temp": 31.0},
|
"current": {"temp": 31.0},
|
||||||
@@ -156,5 +156,5 @@ def test_high_freq_airport_push_forces_analysis_refresh(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert sent is True
|
assert sent is True
|
||||||
assert calls == [("qingdao", True)]
|
assert calls == [("qingdao", False, True)]
|
||||||
assert bot.messages
|
assert bot.messages
|
||||||
|
|||||||
Reference in New Issue
Block a user