feat: implement scan terminal dashboard with AI-driven city analysis and state management
This commit is contained in:
+8
-2
@@ -134,12 +134,18 @@ POLYWEATHER_DEEPSEEK_API_KEY=
|
|||||||
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro
|
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-pro
|
||||||
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40
|
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40
|
||||||
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=32
|
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=45
|
||||||
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=120
|
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=1800
|
||||||
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
POLYWEATHER_SCAN_AI_MAX_ROWS=40
|
||||||
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
|
||||||
|
POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=1200
|
||||||
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
|
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=55000
|
||||||
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
|
||||||
|
POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC=1800
|
||||||
|
POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC=1800
|
||||||
|
POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC=1800
|
||||||
|
POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC=1800
|
||||||
|
POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC=1800
|
||||||
|
|
||||||
# Weekly reward / leaderboard
|
# Weekly reward / leaderboard
|
||||||
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
|
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export async function POST(req: NextRequest) {
|
|||||||
console.info("[scan-ai-city] proxy request", {
|
console.info("[scan-ai-city] proxy request", {
|
||||||
city: requestBody.city,
|
city: requestBody.city,
|
||||||
force_refresh: requestBody.force_refresh === true,
|
force_refresh: requestBody.force_refresh === true,
|
||||||
|
locale: requestBody.locale,
|
||||||
});
|
});
|
||||||
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, {
|
const res = await fetch(`${API_BASE}/api/scan/terminal/ai-city`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -643,6 +643,7 @@ function AiPinnedCityCard({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
city: detailCityName,
|
city: detailCityName,
|
||||||
force_refresh: aiRefreshToken > 0,
|
force_refresh: aiRefreshToken > 0,
|
||||||
|
locale,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
@@ -664,7 +665,7 @@ function AiPinnedCityCard({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [aiForecastKey, aiRefreshToken, detailCityName]);
|
}, [aiForecastKey, aiRefreshToken, detailCityName, locale]);
|
||||||
|
|
||||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||||
const localizedFinalJudgment =
|
const localizedFinalJudgment =
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ function getInitialProAccessState(): ProAccessState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
|
const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
|
||||||
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
|
|
||||||
const CITY_LOAD_RETRY_DELAYS_MS = [700, 1600];
|
const CITY_LOAD_RETRY_DELAYS_MS = [700, 1600];
|
||||||
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
|
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
|
||||||
|
|
||||||
@@ -228,10 +227,6 @@ function detailSatisfiesDepth(
|
|||||||
return normalizeDetailDepth(detail) === "full";
|
return normalizeDetailDepth(detail) === "full";
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldCheckSparseCoverageForDepth(depth: CityDetailDepth) {
|
|
||||||
return depth === "panel" || depth === "market" || depth === "full";
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasMeaningfulModelMap(
|
function hasMeaningfulModelMap(
|
||||||
value: Record<string, number | null> | undefined,
|
value: Record<string, number | null> | undefined,
|
||||||
): value is Record<string, number | null> {
|
): value is Record<string, number | null> {
|
||||||
@@ -399,7 +394,6 @@ export function DashboardStoreProvider({
|
|||||||
const modalOpenSeqRef = useRef(0);
|
const modalOpenSeqRef = useRef(0);
|
||||||
const hydratedSelectionRef = useRef(false);
|
const hydratedSelectionRef = useRef(false);
|
||||||
const hydratedProCacheRef = useRef(false);
|
const hydratedProCacheRef = useRef(false);
|
||||||
const backgroundSummaryCheckAtRef = useRef<Record<string, number>>({});
|
|
||||||
const summaryInflightByCityRef = useRef<Record<string, Promise<CitySummary>>>(
|
const summaryInflightByCityRef = useRef<Record<string, Promise<CitySummary>>>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@@ -474,32 +468,36 @@ export function DashboardStoreProvider({
|
|||||||
dashboardClient.clearCityDetailCache();
|
dashboardClient.clearCityDetailCache();
|
||||||
}, [proAccess]);
|
}, [proAccess]);
|
||||||
|
|
||||||
const scheduleBackgroundDetailRefresh = (
|
const ensureCityDetail = async (
|
||||||
cityName: string,
|
cityName: string,
|
||||||
cached: CityDetail,
|
force = false,
|
||||||
cachedMeta?: { cachedAt: number; revision: string },
|
depth: CityDetailDepth = "panel",
|
||||||
) => {
|
) => {
|
||||||
const nowTs = Date.now();
|
const cached = cityDetailsByName[cityName];
|
||||||
const lastTs = backgroundSummaryCheckAtRef.current[cityName] || 0;
|
const cachedMeta = cityDetailMetaByName[cityName];
|
||||||
if (nowTs - lastTs < BACKGROUND_SUMMARY_REFRESH_MS) {
|
const marketTargetDate =
|
||||||
return;
|
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
||||||
|
const hasRequestedDepth = detailSatisfiesDepth(
|
||||||
|
cached,
|
||||||
|
depth,
|
||||||
|
marketTargetDate,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
cached &&
|
||||||
|
hasRequestedDepth &&
|
||||||
|
dashboardClient.isCityDetailFresh(cachedMeta)
|
||||||
|
) {
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
backgroundSummaryCheckAtRef.current[cityName] = nowTs;
|
|
||||||
|
|
||||||
void dashboardClient
|
|
||||||
.getCitySummary(cityName)
|
|
||||||
.then(async (summary) => {
|
|
||||||
const revision = getCityRevision(summary);
|
|
||||||
if (!revision || revision === cachedMeta?.revision) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!force && cached && hasRequestedDepth) {
|
||||||
|
try {
|
||||||
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
||||||
force: false,
|
force: true,
|
||||||
depth: normalizeDetailDepth(cached),
|
depth,
|
||||||
});
|
});
|
||||||
const detail = latestDetail;
|
const detail = latestDetail;
|
||||||
|
|
||||||
setCityDetailsByName((current) => ({
|
setCityDetailsByName((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
[cityName]: mergeCityDetail(current[cityName], detail),
|
||||||
@@ -515,77 +513,7 @@ export function DashboardStoreProvider({
|
|||||||
revision: getCityRevision(detail),
|
revision: getCityRevision(detail),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
})
|
return detail;
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureCityDetail = async (
|
|
||||||
cityName: string,
|
|
||||||
force = false,
|
|
||||||
depth: CityDetailDepth = "panel",
|
|
||||||
) => {
|
|
||||||
const cached = cityDetailsByName[cityName];
|
|
||||||
const cachedMeta = cityDetailMetaByName[cityName];
|
|
||||||
const marketTargetDate =
|
|
||||||
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
|
||||||
const hasRequestedDepth = detailSatisfiesDepth(
|
|
||||||
cached,
|
|
||||||
depth,
|
|
||||||
marketTargetDate,
|
|
||||||
);
|
|
||||||
const cachedIsSparse =
|
|
||||||
shouldCheckSparseCoverageForDepth(depth) &&
|
|
||||||
(depth === "market"
|
|
||||||
? hasSparseModelCoverage(cached, marketTargetDate)
|
|
||||||
: hasSparseDetailCoverage(cached, cached?.local_date));
|
|
||||||
if (
|
|
||||||
!force &&
|
|
||||||
cached &&
|
|
||||||
hasRequestedDepth &&
|
|
||||||
!cachedIsSparse &&
|
|
||||||
dashboardClient.isCityDetailFresh(cachedMeta)
|
|
||||||
) {
|
|
||||||
scheduleBackgroundDetailRefresh(cityName, cached, cachedMeta);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!force && cached && hasRequestedDepth) {
|
|
||||||
try {
|
|
||||||
const summary = await dashboardClient.getCitySummary(cityName);
|
|
||||||
const revision = getCityRevision(summary);
|
|
||||||
if (revision && revision === cachedMeta?.revision) {
|
|
||||||
if (cachedIsSparse) {
|
|
||||||
const latestDetail = await dashboardClient.getCityDetail(cityName, {
|
|
||||||
force: true,
|
|
||||||
depth,
|
|
||||||
});
|
|
||||||
const detail = latestDetail;
|
|
||||||
setCityDetailsByName((current) => ({
|
|
||||||
...current,
|
|
||||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
|
||||||
}));
|
|
||||||
setCitySummariesByName((current) => ({
|
|
||||||
...current,
|
|
||||||
[cityName]: toCitySummary(detail),
|
|
||||||
}));
|
|
||||||
setCityDetailMetaByName((current) => ({
|
|
||||||
...current,
|
|
||||||
[cityName]: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
revision: getCityRevision(detail),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return detail;
|
|
||||||
}
|
|
||||||
setCityDetailMetaByName((current) => ({
|
|
||||||
...current,
|
|
||||||
[cityName]: {
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
revision,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export type AssistantChatResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_KEY = "polyWeather_v1";
|
const CACHE_KEY = "polyWeather_v1";
|
||||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
|
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
|
||||||
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||||
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
|
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
|
||||||
@@ -244,16 +244,18 @@ function readLegacyCache(raw: string): CityCacheBundle {
|
|||||||
};
|
};
|
||||||
const details = parsed.data || {};
|
const details = parsed.data || {};
|
||||||
const cachedAt = parsed.timestamp || 0;
|
const cachedAt = parsed.timestamp || 0;
|
||||||
const meta = Object.fromEntries(
|
const freshDetails: Record<string, CityDetail> = {};
|
||||||
Object.entries(details).map(([cityName, detail]) => [
|
const meta: Record<string, CityCacheMeta> = {};
|
||||||
cityName,
|
Object.entries(details).forEach(([cityName, detail]) => {
|
||||||
{
|
const nextMeta = {
|
||||||
cachedAt,
|
cachedAt,
|
||||||
revision: getCityRevision(detail),
|
revision: getCityRevision(detail),
|
||||||
},
|
};
|
||||||
]),
|
if (!isFresh(nextMeta)) return;
|
||||||
);
|
freshDetails[cityName] = detail;
|
||||||
return { details, meta };
|
meta[cityName] = nextMeta;
|
||||||
|
});
|
||||||
|
return { details: freshDetails, meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dashboardClient = {
|
export const dashboardClient = {
|
||||||
@@ -606,11 +608,13 @@ export const dashboardClient = {
|
|||||||
const meta: Record<string, CityCacheMeta> = {};
|
const meta: Record<string, CityCacheMeta> = {};
|
||||||
Object.entries(parsed.entries).forEach(([cityName, entry]) => {
|
Object.entries(parsed.entries).forEach(([cityName, entry]) => {
|
||||||
if (!entry?.detail) return;
|
if (!entry?.detail) return;
|
||||||
details[cityName] = entry.detail;
|
const nextMeta = {
|
||||||
meta[cityName] = {
|
|
||||||
cachedAt: entry.cachedAt || 0,
|
cachedAt: entry.cachedAt || 0,
|
||||||
revision: entry.revision || getCityRevision(entry.detail),
|
revision: entry.revision || getCityRevision(entry.detail),
|
||||||
};
|
};
|
||||||
|
if (!isFresh(nextMeta)) return;
|
||||||
|
details[cityName] = entry.detail;
|
||||||
|
meta[cityName] = nextMeta;
|
||||||
});
|
});
|
||||||
return { details, meta };
|
return { details, meta };
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-9
@@ -147,10 +147,10 @@ US_CORE_CITIES = [
|
|||||||
"atlanta",
|
"atlanta",
|
||||||
"seattle",
|
"seattle",
|
||||||
]
|
]
|
||||||
CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "180")))
|
CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "1800")))
|
||||||
CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "300")))
|
CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "1800")))
|
||||||
CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "480")))
|
CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "1800")))
|
||||||
CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900")))
|
CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "1800")))
|
||||||
MARKET_SCAN_PAYLOAD_TTL_SEC = max(
|
MARKET_SCAN_PAYLOAD_TTL_SEC = max(
|
||||||
5,
|
5,
|
||||||
int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")),
|
int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")),
|
||||||
@@ -969,7 +969,7 @@ async def city_detail(
|
|||||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "panel", city)
|
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "panel", city)
|
||||||
if cached_entry:
|
if cached_entry:
|
||||||
if not _city_cache_is_fresh(cached_entry, CITY_PANEL_CACHE_TTL_SEC):
|
if not _city_cache_is_fresh(cached_entry, CITY_PANEL_CACHE_TTL_SEC):
|
||||||
_schedule_cache_refresh(background_tasks, kind="panel", city=city)
|
return await run_in_threadpool(_refresh_city_panel_cache, city, False)
|
||||||
return cached_entry.get("payload") or {}
|
return cached_entry.get("payload") or {}
|
||||||
return await run_in_threadpool(_refresh_city_panel_cache, city, False)
|
return await run_in_threadpool(_refresh_city_panel_cache, city, False)
|
||||||
if detail_mode == "nearby":
|
if detail_mode == "nearby":
|
||||||
@@ -978,7 +978,7 @@ async def city_detail(
|
|||||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "nearby", city)
|
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "nearby", city)
|
||||||
if cached_entry:
|
if cached_entry:
|
||||||
if not _city_cache_is_fresh(cached_entry, CITY_NEARBY_CACHE_TTL_SEC):
|
if not _city_cache_is_fresh(cached_entry, CITY_NEARBY_CACHE_TTL_SEC):
|
||||||
_schedule_cache_refresh(background_tasks, kind="nearby", city=city)
|
return await run_in_threadpool(_refresh_city_nearby_cache, city, False)
|
||||||
return cached_entry.get("payload") or {}
|
return cached_entry.get("payload") or {}
|
||||||
return await run_in_threadpool(_refresh_city_nearby_cache, city, False)
|
return await run_in_threadpool(_refresh_city_nearby_cache, city, False)
|
||||||
if detail_mode == "market":
|
if detail_mode == "market":
|
||||||
@@ -987,7 +987,7 @@ async def city_detail(
|
|||||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
|
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
|
||||||
if cached_entry:
|
if cached_entry:
|
||||||
if not _market_analysis_cache_is_fresh(cached_entry):
|
if not _market_analysis_cache_is_fresh(cached_entry):
|
||||||
_schedule_cache_refresh(background_tasks, kind="market", city=city)
|
return await run_in_threadpool(_refresh_city_market_cache, city, False)
|
||||||
return cached_entry.get("payload") or {}
|
return cached_entry.get("payload") or {}
|
||||||
return await run_in_threadpool(_refresh_city_market_cache, city, False)
|
return await run_in_threadpool(_refresh_city_market_cache, city, False)
|
||||||
return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode)
|
return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode)
|
||||||
@@ -1007,7 +1007,7 @@ async def city_history(
|
|||||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "history_preview", city)
|
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "history_preview", city)
|
||||||
if cached_entry:
|
if cached_entry:
|
||||||
if not _city_cache_is_fresh(cached_entry, CITY_HISTORY_PREVIEW_CACHE_TTL_SEC):
|
if not _city_cache_is_fresh(cached_entry, CITY_HISTORY_PREVIEW_CACHE_TTL_SEC):
|
||||||
_schedule_cache_refresh(background_tasks, kind="history_preview", city=city)
|
return await run_in_threadpool(_refresh_city_history_preview_cache, city)
|
||||||
return cached_entry.get("payload") or {}
|
return cached_entry.get("payload") or {}
|
||||||
return await run_in_threadpool(_refresh_city_history_preview_cache, city)
|
return await run_in_threadpool(_refresh_city_history_preview_cache, city)
|
||||||
|
|
||||||
@@ -1610,7 +1610,7 @@ async def city_summary(
|
|||||||
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "summary", city)
|
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "summary", city)
|
||||||
if cached_entry:
|
if cached_entry:
|
||||||
if not _city_cache_is_fresh(cached_entry, CITY_SUMMARY_CACHE_TTL_SEC):
|
if not _city_cache_is_fresh(cached_entry, CITY_SUMMARY_CACHE_TTL_SEC):
|
||||||
_schedule_cache_refresh(background_tasks, kind="summary", city=city)
|
return await run_in_threadpool(_refresh_city_summary_cache, city, False)
|
||||||
return cached_entry.get("payload") or {}
|
return cached_entry.get("payload") or {}
|
||||||
return await run_in_threadpool(_refresh_city_summary_cache, city, False)
|
return await run_in_threadpool(_refresh_city_summary_cache, city, False)
|
||||||
|
|
||||||
@@ -1769,9 +1769,11 @@ async def scan_terminal_ai_city(request: Request):
|
|||||||
"yes",
|
"yes",
|
||||||
"on",
|
"on",
|
||||||
}
|
}
|
||||||
|
locale = str(body.get("locale") or "zh-CN").strip()
|
||||||
return await run_in_threadpool(
|
return await run_in_threadpool(
|
||||||
build_scan_city_ai_forecast_payload,
|
build_scan_city_ai_forecast_payload,
|
||||||
city,
|
city,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
|
locale=locale,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ SCAN_AI_TIMEOUT_SEC = max(
|
|||||||
)
|
)
|
||||||
SCAN_CITY_AI_TIMEOUT_SEC = max(
|
SCAN_CITY_AI_TIMEOUT_SEC = max(
|
||||||
20,
|
20,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", "32")),
|
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", "45")),
|
||||||
)
|
)
|
||||||
SCAN_AI_CACHE_TTL_SEC = max(
|
SCAN_AI_CACHE_TTL_SEC = max(
|
||||||
30,
|
30,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "120")),
|
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "1800")),
|
||||||
)
|
)
|
||||||
SCAN_AI_MAX_ROWS = max(
|
SCAN_AI_MAX_ROWS = max(
|
||||||
1,
|
1,
|
||||||
@@ -61,6 +61,10 @@ SCAN_AI_MAX_TOKENS = max(
|
|||||||
600,
|
600,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "3200")),
|
int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "3200")),
|
||||||
)
|
)
|
||||||
|
SCAN_CITY_AI_MAX_TOKENS = max(
|
||||||
|
500,
|
||||||
|
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS", "1200")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: Any) -> Optional[float]:
|
def _safe_float(value: Any) -> Optional[float]:
|
||||||
@@ -79,6 +83,11 @@ def _safe_int(value: Any, default: int) -> int:
|
|||||||
return int(default)
|
return int(default)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_locale(value: Any) -> str:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
return "en-US" if text.startswith("en") else "zh-CN"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_scan_terminal_filters(
|
def _normalize_scan_terminal_filters(
|
||||||
raw_filters: Optional[Dict[str, Any]] = None,
|
raw_filters: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -892,10 +901,14 @@ def _build_city_ai_prompt(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
def _call_deepseek_city_ai(ai_input: Dict[str, Any], *, locale: str = "zh-CN") -> Dict[str, Any]:
|
||||||
api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip()
|
api_key = str(os.getenv("POLYWEATHER_DEEPSEEK_API_KEY") or "").strip()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured")
|
raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured")
|
||||||
|
normalized_locale = _normalize_locale(locale)
|
||||||
|
primary_language = "English" if normalized_locale == "en-US" else "Simplified Chinese"
|
||||||
|
primary_suffix = "_en" if normalized_locale == "en-US" else "_zh"
|
||||||
|
secondary_suffix = "_zh" if normalized_locale == "en-US" else "_en"
|
||||||
|
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
"你是 PolyWeather 的 Deepseek V4-Pro 城市最高温预测员。你必须直接阅读用户给出的城市 JSON,"
|
"你是 PolyWeather 的 Deepseek V4-Pro 城市最高温预测员。你必须直接阅读用户给出的城市 JSON,"
|
||||||
@@ -906,15 +919,21 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"你可以基于城市、时间、季节、机场位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径,"
|
"你可以基于城市、时间、季节、机场位置、风向/风速、云、能见度、露点等判断风或天气是否可能影响温度路径,"
|
||||||
"但必须使用“可能”“倾向”“需要确认”等非绝对表达。"
|
"但必须使用“可能”“倾向”“需要确认”等非绝对表达。"
|
||||||
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。"
|
"如果峰值窗口尚未到来,不能过早下最终结论;如果峰值窗口已过或实测已创高,需要更重视 METAR 实测。"
|
||||||
|
f"当前用户界面语言是 {normalized_locale},所有面向用户的主要自然语言字段必须使用 {primary_language}。"
|
||||||
|
f"重点填写 {primary_suffix} 字段;{secondary_suffix} 字段可以给极短镜像翻译,但不能影响响应速度。"
|
||||||
|
"risks 最多 2 条,reasoning、metar_read、model_cluster_note 各 1 句。"
|
||||||
"只返回 JSON object,不要 Markdown。"
|
"只返回 JSON object,不要 Markdown。"
|
||||||
)
|
)
|
||||||
user_payload = {
|
user_payload = {
|
||||||
|
"locale": normalized_locale,
|
||||||
|
"primary_language": primary_language,
|
||||||
"task": (
|
"task": (
|
||||||
"Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, "
|
"Return strict JSON with: predicted_max, range_low, range_high, unit, confidence, "
|
||||||
"final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, "
|
"final_judgment_zh, final_judgment_en, metar_read_zh, metar_read_en, "
|
||||||
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. "
|
"reasoning_zh, reasoning_en, risks_zh, risks_en, model_cluster_note_zh, model_cluster_note_en. "
|
||||||
|
f"Primary output language is {primary_language}; the UI will read fields ending with {primary_suffix}. "
|
||||||
"Keep final_judgment one short decision sentence. metar_read should explain the latest airport bulletin "
|
"Keep final_judgment one short decision sentence. metar_read should explain the latest airport bulletin "
|
||||||
"and how wind/cloud/visibility/dewpoint may affect the temperature path."
|
"and how wind/cloud/visibility/dewpoint may affect the temperature path. Keep the whole JSON compact."
|
||||||
),
|
),
|
||||||
"city_snapshot": ai_input,
|
"city_snapshot": ai_input,
|
||||||
}
|
}
|
||||||
@@ -925,6 +944,27 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
write=10.0,
|
write=10.0,
|
||||||
pool=5.0,
|
pool=5.0,
|
||||||
)
|
)
|
||||||
|
request_json = {
|
||||||
|
"model": SCAN_AI_MODEL,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": min(max(SCAN_CITY_AI_MAX_TOKENS, 700), 1400),
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": json.dumps(user_payload, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"scan city AI provider request city={} locale={} input_bytes={} max_tokens={} timeout_sec={}",
|
||||||
|
ai_input.get("city"),
|
||||||
|
normalized_locale,
|
||||||
|
len(json.dumps(request_json, ensure_ascii=False, default=str).encode("utf-8")),
|
||||||
|
request_json.get("max_tokens"),
|
||||||
|
SCAN_CITY_AI_TIMEOUT_SEC,
|
||||||
|
)
|
||||||
with httpx.Client(timeout=timeout) as client:
|
with httpx.Client(timeout=timeout) as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"{SCAN_AI_BASE_URL}/chat/completions",
|
f"{SCAN_AI_BASE_URL}/chat/completions",
|
||||||
@@ -932,19 +972,7 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json=request_json,
|
||||||
"model": SCAN_AI_MODEL,
|
|
||||||
"temperature": 0.2,
|
|
||||||
"max_tokens": min(max(SCAN_AI_MAX_TOKENS, 1200), 2400),
|
|
||||||
"response_format": {"type": "json_object"},
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": json.dumps(user_payload, ensure_ascii=False),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -979,32 +1007,28 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
city: str,
|
city: str,
|
||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
|
locale: str = "zh-CN",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
started_at = time.time()
|
started_at = time.time()
|
||||||
city_name = str(city or "").strip()
|
city_name = str(city or "").strip()
|
||||||
|
normalized_locale = _normalize_locale(locale)
|
||||||
if not city_name:
|
if not city_name:
|
||||||
return {"status": "failed", "reason": "city is required"}
|
return {"status": "failed", "reason": "city is required"}
|
||||||
logger.info(
|
logger.info(
|
||||||
"scan city AI forecast requested city={} force_refresh={} model={}",
|
"scan city AI forecast requested city={} force_refresh={} locale={} model={}",
|
||||||
city_name,
|
city_name,
|
||||||
force_refresh,
|
force_refresh,
|
||||||
|
normalized_locale,
|
||||||
SCAN_AI_MODEL,
|
SCAN_AI_MODEL,
|
||||||
)
|
)
|
||||||
data = _analyze(
|
cache_key = f"city_forecast:{city_name.lower()}:{normalized_locale}"
|
||||||
city_name,
|
|
||||||
force_refresh=False,
|
|
||||||
include_llm_commentary=False,
|
|
||||||
detail_mode="full",
|
|
||||||
)
|
|
||||||
ai_input = _build_city_ai_prompt(data)
|
|
||||||
cache_key = _scan_city_ai_cache_key(ai_input)
|
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
with _SCAN_CITY_AI_CACHE_LOCK:
|
with _SCAN_CITY_AI_CACHE_LOCK:
|
||||||
cached = _SCAN_CITY_AI_CACHE.get(cache_key)
|
cached = _SCAN_CITY_AI_CACHE.get(cache_key)
|
||||||
if cached and cached.get("expires_at", 0) >= time.time():
|
if cached and cached.get("expires_at", 0) >= time.time():
|
||||||
logger.info(
|
logger.info(
|
||||||
"scan city AI forecast cache hit city={} model={}",
|
"scan city AI forecast cache hit city={} model={}",
|
||||||
data.get("name") or city_name,
|
cached.get("city") or city_name,
|
||||||
SCAN_AI_MODEL,
|
SCAN_AI_MODEL,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -1012,12 +1036,19 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
"cached": True,
|
"cached": True,
|
||||||
"model": SCAN_AI_MODEL,
|
"model": SCAN_AI_MODEL,
|
||||||
"provider": "deepseek",
|
"provider": "deepseek",
|
||||||
"city": data.get("name") or city_name,
|
"city": cached.get("city") or city_name,
|
||||||
"city_display_name": data.get("display_name") or city_name,
|
"city_display_name": cached.get("city_display_name") or city_name,
|
||||||
"generated_at": cached.get("generated_at"),
|
"generated_at": cached.get("generated_at"),
|
||||||
"duration_ms": 0,
|
"duration_ms": 0,
|
||||||
"city_forecast": cached.get("payload"),
|
"city_forecast": cached.get("payload"),
|
||||||
}
|
}
|
||||||
|
data = _analyze(
|
||||||
|
city_name,
|
||||||
|
force_refresh=False,
|
||||||
|
include_llm_commentary=False,
|
||||||
|
detail_mode="full",
|
||||||
|
)
|
||||||
|
ai_input = _build_city_ai_prompt(data)
|
||||||
|
|
||||||
if not SCAN_AI_ENABLED:
|
if not SCAN_AI_ENABLED:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1060,7 +1091,7 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
else False
|
else False
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
ai_raw = _call_deepseek_city_ai(ai_input)
|
ai_raw = _call_deepseek_city_ai(ai_input, locale=normalized_locale)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
duration_ms = int((time.time() - started_at) * 1000)
|
duration_ms = int((time.time() - started_at) * 1000)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1077,7 +1108,13 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
"city": data.get("name") or city_name,
|
"city": data.get("name") or city_name,
|
||||||
"city_display_name": data.get("display_name") or city_name,
|
"city_display_name": data.get("display_name") or city_name,
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
"reason": f"V4 provider timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s",
|
"reason": (
|
||||||
|
f"DeepSeek V4-Pro timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s"
|
||||||
|
if normalized_locale == "en-US"
|
||||||
|
else f"DeepSeek V4-Pro 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回"
|
||||||
|
),
|
||||||
|
"reason_en": f"DeepSeek V4-Pro timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s",
|
||||||
|
"reason_zh": f"DeepSeek V4-Pro 在 {SCAN_CITY_AI_TIMEOUT_SEC} 秒内未返回",
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = int((time.time() - started_at) * 1000)
|
duration_ms = int((time.time() - started_at) * 1000)
|
||||||
@@ -1102,6 +1139,8 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
_SCAN_CITY_AI_CACHE[cache_key] = {
|
_SCAN_CITY_AI_CACHE[cache_key] = {
|
||||||
"expires_at": time.time() + SCAN_AI_CACHE_TTL_SEC,
|
"expires_at": time.time() + SCAN_AI_CACHE_TTL_SEC,
|
||||||
"generated_at": generated_at,
|
"generated_at": generated_at,
|
||||||
|
"city": data.get("name") or city_name,
|
||||||
|
"city_display_name": data.get("display_name") or city_name,
|
||||||
"payload": ai_raw,
|
"payload": ai_raw,
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user