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);
|
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(
|
function getRoundedWeatherBucketValue(
|
||||||
expectedHigh: number | null,
|
expectedHigh: number | null,
|
||||||
tempSymbol: string,
|
tempSymbol: string,
|
||||||
@@ -212,9 +230,16 @@ export function pickMarketBucketForWeatherCenter(
|
|||||||
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
|
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
|
||||||
const anchor = getBucketAnchor(bucket);
|
const anchor = getBucketAnchor(bucket);
|
||||||
if (comparable == null || anchor == null) return false;
|
if (comparable == null || anchor == null) return false;
|
||||||
const unit = String(bucket.unit || "").toUpperCase();
|
const roundedTarget = Math.round(comparable);
|
||||||
const maxReasonableDelta = unit === "F" ? 16 : 8;
|
const roundedAnchor = Math.round(anchor);
|
||||||
return Math.abs(anchor - comparable) <= maxReasonableDelta;
|
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)) {
|
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
|
||||||
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
|
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
|||||||
import { extractStreamingAirportRead } from "./ai-city-stream";
|
import { extractStreamingAirportRead } from "./ai-city-stream";
|
||||||
import { normalizeCityKey } from "./decision-utils";
|
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 AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v2";
|
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v2";
|
||||||
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
|
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
@@ -357,10 +357,26 @@ export function useAiCityForecast({
|
|||||||
});
|
});
|
||||||
const [aiRefreshToken, setAiRefreshToken] = useState(0);
|
const [aiRefreshToken, setAiRefreshToken] = useState(0);
|
||||||
const aiForecastKey = useMemo(
|
const aiForecastKey = useMemo(
|
||||||
() =>
|
() => {
|
||||||
detail
|
if (!detail) return "";
|
||||||
? `${normalizeCityKey(detailCityName)}:${detail.local_date || ""}:${locale}:${report || ""}`
|
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],
|
[detail, detailCityName, locale, report],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -423,19 +439,19 @@ export function useAiCityForecast({
|
|||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!payload) return;
|
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) {
|
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" });
|
setAiForecast({ payload: usablePayload, status: "ready" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -529,7 +545,7 @@ export function useCityMarketScan({
|
|||||||
setMarketStatus("loading");
|
setMarketStatus("loading");
|
||||||
}
|
}
|
||||||
void ensureCityMarketScan(detailCityName, false, {
|
void ensureCityMarketScan(detailCityName, false, {
|
||||||
lite: true,
|
lite: false,
|
||||||
targetDate: detail.local_date || null,
|
targetDate: detail.local_date || null,
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
|
|||||||
@@ -1691,7 +1691,6 @@ def _scan_city_ai_cache_key(ai_input: Dict[str, Any]) -> str:
|
|||||||
"model": SCAN_CITY_AI_MODEL,
|
"model": SCAN_CITY_AI_MODEL,
|
||||||
"city": ai_input.get("city"),
|
"city": ai_input.get("city"),
|
||||||
"local_date": ai_input.get("local_date"),
|
"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,
|
"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,
|
"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 [],
|
"obs": ai_input.get("metar_today_obs") or ai_input.get("metar_recent_obs") or [],
|
||||||
|
|||||||
Reference in New Issue
Block a user