diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index b024bc60..98eff311 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -142,6 +142,24 @@ function getBucketAnchor(bucket: MarketTopBucket) { return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower); } +function marketBucketLabelText(bucket?: MarketTopBucket | null) { + return String(`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`) + .trim() + .toLowerCase(); +} + +function isMarketBucketAbove(bucket?: MarketTopBucket | null) { + return /\b(or[-\s]?higher|or[-\s]?above|above|higher|at least|greater than)\b/i.test( + marketBucketLabelText(bucket), + ); +} + +function isMarketBucketBelow(bucket?: MarketTopBucket | null) { + return /\b(or[-\s]?lower|or[-\s]?below|below|lower|at most|less than)\b/i.test( + marketBucketLabelText(bucket), + ); +} + function getRoundedWeatherBucketValue( expectedHigh: number | null, tempSymbol: string, @@ -212,9 +230,16 @@ export function pickMarketBucketForWeatherCenter( const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); const anchor = getBucketAnchor(bucket); if (comparable == null || anchor == null) return false; - const unit = String(bucket.unit || "").toUpperCase(); - const maxReasonableDelta = unit === "F" ? 16 : 8; - return Math.abs(anchor - comparable) <= maxReasonableDelta; + const roundedTarget = Math.round(comparable); + const roundedAnchor = Math.round(anchor); + const lower = bucket.lower != null ? Number(bucket.lower) : anchor; + const upper = bucket.upper != null ? Number(bucket.upper) : null; + if (upper != null && Number.isFinite(lower) && Number.isFinite(upper)) { + return roundedTarget >= lower - 0.01 && roundedTarget <= upper + 0.01; + } + if (isMarketBucketAbove(bucket)) return roundedTarget >= roundedAnchor; + if (isMarketBucketBelow(bucket)) return roundedTarget <= roundedAnchor; + return roundedAnchor === roundedTarget; }; if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) { return isReasonableFallback(selectedBucket) ? selectedBucket : null; diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 0afe3850..40be974b 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -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_v2"; +const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v3"; const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000; const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v2"; const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000; @@ -357,10 +357,26 @@ export function useAiCityForecast({ }); const [aiRefreshToken, setAiRefreshToken] = useState(0); const aiForecastKey = useMemo( - () => - detail - ? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${locale}:${report || ""}` - : "", + () => { + if (!detail) return ""; + const airportCurrent = detail.airport_current || detail.current || {}; + const metarSignature = + String(report || "").trim() || + [ + airportCurrent.report_time, + airportCurrent.obs_time_epoch, + airportCurrent.obs_time, + airportCurrent.temp, + ] + .filter((part) => part != null && part !== "") + .join("|"); + return [ + normalizeCityKey(detailCityName), + detail.local_date || "", + locale, + metarSignature, + ].join(":"); + }, [detail, detailCityName, locale, report], ); useEffect(() => { @@ -423,19 +439,19 @@ export function useAiCityForecast({ }) .then((payload) => { if (!payload) return; + 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); + } if (!cancelled) { - 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); - } setAiForecast({ payload: usablePayload, status: "ready" }); } }) @@ -529,7 +545,7 @@ export function useCityMarketScan({ setMarketStatus("loading"); } void ensureCityMarketScan(detailCityName, false, { - lite: true, + lite: false, targetDate: detail.local_date || null, }) .then((payload) => { diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index bb5329b6..8fcd3a3f 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -1691,7 +1691,6 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str: "model": SCAN_CITY_AI_MODEL, "city": ai_input.get("city"), "local_date": ai_input.get("local_date"), - "local_time": ai_input.get("local_time"), "deb": (ai_input.get("deb") or {}).get("prediction") if isinstance(ai_input.get("deb"), dict) else None, "metar": (ai_input.get("airport_current") or {}).get("raw_metar") if isinstance(ai_input.get("airport_current"), dict) else None, "obs": ai_input.get("metar_today_obs") or ai_input.get("metar_recent_obs") or [],