feat: implement market scan data utilities and service for city weather card decisioning
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
Reference in New Issue
Block a user