feat: implement weather dashboard components, state management, and API routes for terminal scanning and assistant chat

This commit is contained in:
2569718930@qq.com
2026-04-25 06:42:24 +08:00
parent 3083d6b8fe
commit d5fb40a544
13 changed files with 2109 additions and 357 deletions
+2 -2
View File
@@ -134,9 +134,9 @@ POLYWEATHER_DEEPSEEK_API_KEY=
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=600
POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=120
POLYWEATHER_SCAN_AI_MAX_ROWS=40
POLYWEATHER_SCAN_AI_MAX_TOKENS=1600
POLYWEATHER_SCAN_AI_MAX_TOKENS=3200
POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=45000
POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid
+10
View File
@@ -0,0 +1,10 @@
> polyweather-frontend@1.5.4 start
> next start -p 3002
▲ Next.js 15.5.12
- Local: http://localhost:3002
- Network: http://172.23.64.1:3002
✓ Starting...
✓ Ready in 541ms
+11
View File
@@ -4,6 +4,7 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -565,6 +566,16 @@ async function generateWithGroq(params: {
async function ensureAssistantAccess(request: NextRequest) {
const auth = await buildBackendRequestHeaders(request);
const requestHost =
request.headers.get("x-forwarded-host") ||
request.headers.get("host") ||
request.nextUrl.host;
if (
isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(request.nextUrl.hostname)
) {
return { allowed: true, auth };
}
if (process.env.POLYWEATHER_AI_ALLOW_FREE === "true") {
return { allowed: true, auth };
}
+15
View File
@@ -3,10 +3,25 @@ import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
getLocalDevAuthPayload,
isLocalFullAccessHost,
} from "@/lib/local-dev-access";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
const requestHost =
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
if (
isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(req.nextUrl.hostname)
) {
return NextResponse.json(getLocalDevAuthPayload(), {
headers: { "Cache-Control": "no-store" },
});
}
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
@@ -10014,6 +10014,25 @@
background: rgba(59, 130, 246, 0.1);
}
.root :global(.scan-opportunity-strength.approve) {
color: #71f7c8;
border-color: rgba(45, 212, 191, 0.34);
background: rgba(20, 184, 166, 0.12);
}
.root :global(.scan-opportunity-strength.downgrade),
.root :global(.scan-opportunity-strength.watchlist) {
color: #ffd166;
border-color: rgba(245, 158, 11, 0.32);
background: rgba(245, 158, 11, 0.12);
}
.root :global(.scan-opportunity-strength.veto) {
color: #ff8585;
border-color: rgba(248, 113, 113, 0.34);
background: rgba(248, 113, 113, 0.12);
}
.root :global(.scan-opportunity-expand) {
display: inline-flex;
align-items: center;
@@ -10129,6 +10148,367 @@
font-weight: 900;
}
.root :global(.scan-opportunity-group) {
overflow: visible;
border-radius: 14px;
}
.root :global(.scan-opportunity-items) {
gap: 10px;
padding: 10px 12px 14px;
}
.root :global(.scan-opportunity-item) {
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
min-height: 0;
padding: 0;
overflow: visible;
border-color: rgba(89, 128, 178, 0.16);
border-radius: 14px;
background: rgba(6, 18, 32, 0.72);
}
.root :global(.scan-opportunity-item.expanded) {
border-color: rgba(23, 217, 139, 0.42);
background:
linear-gradient(180deg, rgba(8, 29, 43, 0.94), rgba(5, 17, 31, 0.96)),
radial-gradient(circle at 12% 0%, rgba(20, 184, 166, 0.12), transparent 36%);
box-shadow:
inset 0 1px 0 rgba(125, 251, 231, 0.09),
0 18px 42px rgba(0, 0, 0, 0.2);
}
.root :global(.scan-opportunity-summary-row) {
display: grid;
grid-template-columns:
4px minmax(128px, 180px) minmax(360px, 1fr)
max-content max-content;
gap: 10px;
align-items: stretch;
padding: 12px 14px;
}
.root :global(.scan-opportunity-branch) {
align-self: stretch;
width: 4px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(23, 217, 139, 0.92), rgba(34, 211, 238, 0.28));
}
.root :global(.scan-opportunity-branch::before),
.root :global(.scan-opportunity-branch i) {
display: none;
}
.root :global(.scan-opportunity-metrics) {
display: grid;
grid-template-columns: repeat(4, minmax(96px, 1fr));
gap: 8px;
min-width: 0;
}
.root :global(.scan-opportunity-stat) {
display: grid;
align-items: start;
gap: 3px;
min-width: 0;
max-width: none;
width: 100%;
padding: 8px 10px;
border-color: rgba(90, 123, 166, 0.14);
background: rgba(8, 25, 43, 0.62);
}
.root :global(.scan-opportunity-stat.edge) {
justify-self: stretch;
justify-content: stretch;
justify-items: start;
min-width: 0;
}
.root :global(.scan-opportunity-trade) {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
}
.root :global(.scan-opportunity-action) {
white-space: normal;
}
.root :global(.scan-opportunity-ai) {
display: grid;
grid-column: auto;
gap: 4px;
margin: 0 14px;
padding: 11px 13px;
border-color: rgba(64, 104, 168, 0.16);
background: rgba(5, 15, 27, 0.64);
}
.root :global(.scan-opportunity-ai small) {
overflow: visible;
color: #a9bdd8;
white-space: normal;
text-overflow: clip;
}
.root :global(.scan-v4-analysis) {
grid-column: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 14px 14px;
padding: 14px;
border-color: rgba(56, 189, 248, 0.18);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%);
}
.root :global(.scan-v4-analysis section) {
padding: 13px;
border-color: rgba(92, 132, 188, 0.14);
background: rgba(10, 25, 43, 0.7);
}
.root :global(.scan-v4-analysis p),
.root :global(.scan-v4-analysis ul) {
color: #b8c9e3;
}
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: 1 / -1;
}
.root :global(.scan-v4-analysis .scan-v4-decision) {
grid-column: 1 / -1;
border-color: rgba(45, 212, 191, 0.24);
background:
linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)),
radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%);
}
.root :global(.scan-v4-analysis .scan-v4-decision.downgrade),
.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) {
border-color: rgba(245, 158, 11, 0.24);
}
.root :global(.scan-v4-analysis .scan-v4-decision.veto) {
border-color: rgba(248, 113, 113, 0.28);
}
.root :global(.scan-v4-decision p b) {
color: #ecfeff;
font-weight: 950;
}
.root :global(.scan-v4-metar-summary) {
margin-top: 9px;
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-evidence > div),
.root :global(.scan-v4-model-sources) {
gap: 8px;
}
.root :global(.scan-v4-analysis) {
display: flex;
flex-direction: column;
gap: 13px;
margin: 0 14px 14px;
padding: 15px 16px 16px;
border: 1px solid rgba(56, 189, 248, 0.16);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%);
}
.root :global(.scan-v4-analysis section),
.root :global(.scan-v4-analysis section:first-child),
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: auto;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.root :global(.scan-v4-heading) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.14);
}
.root :global(.scan-v4-heading > div) {
display: grid;
gap: 7px;
min-width: 0;
}
.root :global(.scan-v4-heading strong),
.root :global(.scan-v4-current b),
.root :global(.scan-v4-brief-grid strong) {
color: #72f3d1;
font-size: 12px;
font-weight: 950;
letter-spacing: 0.02em;
}
.root :global(.scan-v4-heading p) {
margin: 0;
color: #d2e1f4;
font-size: 14px;
font-weight: 800;
line-height: 1.55;
}
.root :global(.scan-v4-decision-pill) {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border: 1px solid rgba(45, 212, 191, 0.3);
border-radius: 999px;
background: rgba(20, 184, 166, 0.12);
color: #7dfbe7;
font-size: 12px;
font-weight: 950;
white-space: nowrap;
}
.root :global(.scan-v4-decision-pill.downgrade),
.root :global(.scan-v4-decision-pill.watchlist) {
border-color: rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.12);
color: #ffd166;
}
.root :global(.scan-v4-decision-pill.veto) {
border-color: rgba(248, 113, 113, 0.34);
background: rgba(248, 113, 113, 0.12);
color: #ff8585;
}
.root :global(.scan-v4-current) {
display: grid;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(.scan-v4-current span) {
color: #c8d8ec;
font-size: 13px;
font-weight: 850;
line-height: 1.5;
}
.root :global(.scan-v4-current small) {
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-brief-grid) {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr);
gap: 26px;
}
.root :global(.scan-v4-brief-grid section:first-child) {
grid-column: auto;
}
.root :global(.scan-v4-brief-grid ul) {
display: grid;
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.root :global(.scan-v4-brief-grid li) {
position: relative;
padding-left: 16px;
color: #aebfd8;
font-size: 12px;
font-weight: 800;
line-height: 1.48;
}
.root :global(.scan-v4-brief-grid li::before) {
content: "";
position: absolute;
top: 0.65em;
left: 0;
width: 5px;
height: 5px;
border-radius: 999px;
background: #2dd4bf;
box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
}
.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) {
background: #f59e0b;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
}
.root :global(details.scan-v4-evidence) {
padding-top: 11px;
border-top: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(details.scan-v4-evidence summary) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
border: 1px solid rgba(65, 118, 216, 0.28);
border-radius: 8px;
background: rgba(10, 30, 58, 0.52);
color: #8fbdff;
font-size: 12px;
font-weight: 950;
cursor: pointer;
list-style: none;
}
.root :global(details.scan-v4-evidence summary::-webkit-details-marker) {
display: none;
}
.root :global(details.scan-v4-evidence summary::after) {
content: "v";
color: #8fbdff;
font-size: 12px;
}
.root :global(details.scan-v4-evidence[open] summary::after) {
content: "^";
}
.root :global(details.scan-v4-evidence > div) {
margin-top: 10px;
}
.root :global(.scan-table-body::-webkit-scrollbar),
.root :global(.scan-calendar-view::-webkit-scrollbar),
.root :global(.scan-detail-panel::-webkit-scrollbar) {
File diff suppressed because it is too large Load Diff
+23 -34
View File
@@ -831,6 +831,7 @@ export function ProbabilityDistribution({
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const modelView = getModelView(detail, targetDate);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const isToday = !targetDate || targetDate === detail.local_date;
@@ -1216,34 +1217,26 @@ export function ProbabilityDistribution({
<div className="prob-price-card">
<div className="prob-price-head">
<span>
{locale === "en-US" ? "Probability x Price" : "概率 x 价格联动"}
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
</span>
<strong>
{!marketScan
? locale === "en-US"
? "Waiting for market layer"
: "等待市场"
? "Waiting for market context"
: "等待市场参照"
: !marketScan.available
? locale === "en-US"
? "No matched active market"
: "未匹配到活跃盘口"
: linkedMarketBucket
? locale === "en-US"
? `${actionText}: ${linkedContractLabel || "contract bucket"}`
: `${actionText}${linkedContractLabel || "合约桶"}`
: preferredPriceView?.edge != null
? locale === "en-US"
? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}`
: `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}`
: locale === "en-US"
? "Waiting for executable quote"
: "等待可执行报价"}
: locale === "en-US"
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
</strong>
</div>
<div className="prob-price-grid">
<div>
<span>
{locale === "en-US" ? "Contract bucket" : "合约桶口径"}
{locale === "en-US" ? "Bucket" : "温度桶"}
</span>
<strong>
{linkedContractLabel || topProbabilityLabel || "--"}
@@ -1253,33 +1246,29 @@ export function ProbabilityDistribution({
</em>
</div>
<div>
<span>{locale === "en-US" ? "Candidate" : "可关注"}</span>
<strong>{actionText}</strong>
<em>{actionNote}</em>
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
<strong>
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
</div>
<div>
<span>{locale === "en-US" ? "YES price" : "YES 价格"}</span>
<strong>{toPriceCents(yesDisplayPrice) || "--"}</strong>
<em>
{locale === "en-US"
? `edge ${formatSignedPercent(yesDisplayEdge)}`
: `优势 ${formatSignedPercent(yesDisplayEdge)}`}
</em>
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
<strong>{modelVoteHint || "--"}</strong>
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
</div>
<div>
<span>{locale === "en-US" ? "NO price" : "NO 价格"}</span>
<strong>{toPriceCents(noDisplayPrice) || "--"}</strong>
<em>
{locale === "en-US"
? `edge ${formatSignedPercent(noDisplayEdge)}`
: `优势 ${formatSignedPercent(noDisplayEdge)}`}
</em>
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
<em>{quoteSourceLabel}</em>
</div>
</div>
<p>
{locale === "en-US"
? `Read-only comparison between model probability and executable ask; it does not place orders. Source: ${quoteSourceLabel}${lockAvailable ? ` · lock ${formatSignedPercent(lockEdge)}` : ""}.`
: `只比较模型概率与可执行买价;系统不会下单。来源:${quoteSourceLabel}${lockAvailable ? ` · 锁价 ${formatSignedPercent(lockEdge)}` : ""}`}
? "This card follows the same rule as the opportunity list: DEB first, model agreement second, METAR conflict check before settlement."
: "该卡片与机会列表口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
</p>
</div>
)}
@@ -701,7 +701,7 @@ function ScanTerminalScreen() {
const [aiError, setAiError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<ContentView>("list");
const [activeView, setActiveView] = useState<ContentView>("map");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
const [showScanPaywall, setShowScanPaywall] = useState(false);
const [aiLogs, setAiLogs] = useState<ScanAiLogEntry[]>([]);
@@ -1000,7 +1000,7 @@ function ScanTerminalScreen() {
.slice(0, 12);
cities.forEach((cityName) => {
void store.ensureCityDetail(cityName, true, "market").catch(() => {});
void store.ensureCityDetail(cityName, false, "market").catch(() => {});
});
}, [
isPro,
@@ -1086,12 +1086,26 @@ function ScanTerminalScreen() {
setMapSelectedCityName(cityName);
const matchedRow = findRowForCity(timeSortedRows, cityName);
setSelectedRowId(matchedRow?.id || null);
void store.selectCity(cityName);
}, [store, timeSortedRows]);
}, [timeSortedRows]);
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
const cityName = row.city || row.city_display_name || row.display_name || "";
if (!cityName) return;
setSelectedRowId(row.id);
void store.selectCity(row.city);
const selectedCityKey = normalizeCityKey(store.selectedCity);
const rowCityKey = normalizeCityKey(cityName);
const hasCachedDetail =
Boolean(store.cityDetailsByName[cityName]) ||
Object.values(store.cityDetailsByName).some((detail) =>
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
);
if (store.isPanelOpen && selectedCityKey === rowCityKey) {
if (!hasCachedDetail) {
void store.ensureCityDetail(cityName, false, "panel").catch(() => {});
}
return;
}
void store.selectCity(cityName);
}, [store]);
const openScanPaywall = useCallback(() => {
+23 -5
View File
@@ -18,6 +18,10 @@ import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import {
getLocalDevProAccessState,
isBrowserLocalFullAccess,
} from "@/lib/local-dev-access";
import {
CityDetail,
CityListItem,
@@ -94,6 +98,9 @@ function getInitialHistoryState(): HistoryState {
}
function getInitialProAccessState(): ProAccessState {
if (isBrowserLocalFullAccess()) {
return getLocalDevProAccessState();
}
return {
loading: true,
authenticated: false,
@@ -705,6 +712,10 @@ export function DashboardStoreProvider({
};
const refreshProAccess = async () => {
if (isBrowserLocalFullAccess()) {
setProAccess(getLocalDevProAccessState());
return;
}
setProAccess((current) => ({
...current,
loading: true,
@@ -870,10 +881,14 @@ export function DashboardStoreProvider({
]);
const selectCity = async (cityName: string) => {
const wasSelectedCity = selectedCityRef.current === cityName;
const cached = cityDetailsByName[cityName];
selectedCityRef.current = cityName;
setSelectedCity(cityName);
setIsPanelOpen(true);
setSelectedForecastDate(null);
setSelectedForecastDate(
cached?.local_date || (wasSelectedCity ? selectedForecastDate : null),
);
setFutureModalDate(null);
setForecastModalMode(null);
@@ -898,9 +913,10 @@ export function DashboardStoreProvider({
return;
}
const needsDetailRefresh = true;
setLoadingState((current) => ({ ...current, cityDetail: true }));
const detailPromise = ensureCityDetail(cityName, needsDetailRefresh, "panel");
if (!cached) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
}
const detailPromise = ensureCityDetail(cityName, false, "panel");
void Promise.allSettled([summaryPromise, detailPromise])
.then(([, detail]) => {
if (selectedCityRef.current !== cityName) return;
@@ -910,7 +926,9 @@ export function DashboardStoreProvider({
})
.finally(() => {
if (selectedCityRef.current !== cityName) return;
setLoadingState((current) => ({ ...current, cityDetail: false }));
if (!cached) {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
});
};
+54
View File
@@ -455,6 +455,43 @@ export interface ScanOpportunityRow {
temp_symbol?: string | null;
current_temp?: number | null;
current_max_so_far?: number | null;
metar_context?: {
source?: string | null;
station?: string | null;
station_label?: string | null;
obs_count?: number | null;
last_time?: string | null;
last_temp?: number | null;
max_temp?: number | null;
max_time?: string | null;
trend_delta?: number | null;
stale_for_today?: boolean;
available_for_today?: boolean;
last_observation_time?: string | null;
airport_current_temp?: number | null;
airport_max_so_far?: number | null;
airport_obs_time?: string | null;
airport_report_time?: string | null;
airport_raw_metar?: string | null;
airport_wx_desc?: string | null;
airport_cloud_desc?: string | null;
airport_visibility_mi?: number | null;
airport_wind_speed_kt?: number | null;
airport_wind_dir?: number | null;
airport_humidity?: number | null;
today_obs?: Array<{ time?: string; temp?: number | null }>;
recent_obs?: Array<{ time?: string; temp?: number | null }>;
settlement_today_obs?: Array<{ time?: string; temp?: number | null }>;
} | null;
metar_recent_obs?: Array<{ time?: string; temp?: number | null }>;
metar_today_obs?: Array<{ time?: string; temp?: number | null }>;
settlement_today_obs?: Array<{ time?: string; temp?: number | null }>;
metar_status?: {
available_for_today?: boolean;
stale_for_today?: boolean;
last_observation_time?: string | null;
last_temp?: number | null;
} | null;
deb_prediction?: number | null;
airport?: string | null;
risk_level?: RiskLevel | null;
@@ -559,8 +596,25 @@ export interface ScanOpportunityRow {
ai_city_thesis_en?: string | null;
ai_city_confidence?: string | null;
ai_city_model_cluster_note?: string | null;
ai_predicted_max?: number | null;
ai_predicted_low?: number | null;
ai_predicted_high?: number | null;
ai_forecast_unit?: string | null;
ai_forecast_confidence?: string | null;
ai_peak_window_zh?: string | null;
ai_peak_window_en?: string | null;
ai_airport_metar_read_zh?: string | null;
ai_airport_metar_read_en?: string | null;
ai_forecast_reason_zh?: string | null;
ai_forecast_reason_en?: string | null;
ai_forecast_match?: "core" | "edge" | "outside" | "watch" | string | null;
ai_forecast_match_reason_zh?: string | null;
ai_forecast_match_reason_en?: string | null;
ai_watchlist_reason_zh?: string | null;
ai_watchlist_reason_en?: string | null;
v4_metar_decision?: "approve" | "veto" | "downgrade" | "watchlist" | string | null;
v4_metar_reason_zh?: string | null;
v4_metar_reason_en?: string | null;
}
export interface PrimarySignal extends ScanOpportunityRow {}
+83
View File
@@ -0,0 +1,83 @@
import type { ProAccessState } from "@/lib/dashboard-types";
const LOCAL_DEV_EXPIRY = "2099-12-31T23:59:59Z";
const LOCAL_DEV_POINTS = 999_999;
function readLocalAccessFlag() {
const raw =
process.env.NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS ??
process.env.POLYWEATHER_LOCAL_FULL_ACCESS;
if (raw == null) return true;
return !/^(0|false|off|no)$/i.test(String(raw).trim());
}
export function isLocalHostname(value?: string | null) {
const normalized = String(value || "")
.trim()
.toLowerCase()
.replace(/^\[/, "")
.replace(/\]$/, "");
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1"
);
}
export function extractHostname(value?: string | null) {
const firstHost = String(value || "")
.split(",")[0]
.trim();
if (!firstHost) return "";
try {
return new URL(
firstHost.includes("://") ? firstHost : `http://${firstHost}`,
).hostname;
} catch {
if (firstHost.startsWith("[")) {
const endIndex = firstHost.indexOf("]");
return endIndex > 0 ? firstHost.slice(1, endIndex) : firstHost;
}
return firstHost.split(":")[0] || firstHost;
}
}
export function isLocalFullAccessHost(value?: string | null) {
return readLocalAccessFlag() && isLocalHostname(extractHostname(value));
}
export function isBrowserLocalFullAccess() {
if (typeof window === "undefined") return false;
return readLocalAccessFlag() && isLocalHostname(window.location.hostname);
}
export function getLocalDevAuthPayload() {
return {
authenticated: true,
user_id: "local-dev",
email: "local-dev@polyweather.local",
subscription_active: true,
subscription_plan_code: "local-full-access",
subscription_expires_at: LOCAL_DEV_EXPIRY,
subscription_total_expires_at: LOCAL_DEV_EXPIRY,
subscription_queued_days: 0,
subscription_queued_count: 0,
points: LOCAL_DEV_POINTS,
local_dev_full_access: true,
};
}
export function getLocalDevProAccessState(): ProAccessState {
return {
loading: false,
authenticated: true,
userId: "local-dev",
subscriptionActive: true,
subscriptionPlanCode: "local-full-access",
subscriptionExpiresAt: LOCAL_DEV_EXPIRY,
subscriptionTotalExpiresAt: LOCAL_DEV_EXPIRY,
subscriptionQueuedDays: 0,
points: LOCAL_DEV_POINTS,
error: null,
};
}
+11
View File
@@ -3,6 +3,7 @@ import {
createSupabaseMiddlewareClient,
hasSupabaseServerEnv,
} from "@/lib/supabase/server";
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
const SESSION_COOKIE = "polyweather_entitlement";
@@ -181,6 +182,16 @@ export async function middleware(request: NextRequest) {
if (isStaticAsset(pathname)) {
return NextResponse.next();
}
const requestHost =
request.headers.get("x-forwarded-host") ||
request.headers.get("host") ||
request.nextUrl.host;
if (
isLocalFullAccessHost(requestHost) ||
isLocalFullAccessHost(request.nextUrl.hostname)
) {
return NextResponse.next();
}
if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) {
if (SUPABASE_AUTH_REQUIRED) {
+363 -47
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import re
import threading
import time
import hashlib
@@ -44,7 +45,7 @@ SCAN_AI_TIMEOUT_SEC = max(
)
SCAN_AI_CACHE_TTL_SEC = max(
30,
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "600")),
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "120")),
)
SCAN_AI_MAX_ROWS = max(
1,
@@ -52,7 +53,7 @@ SCAN_AI_MAX_ROWS = max(
)
SCAN_AI_MAX_TOKENS = max(
600,
int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "1600")),
int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "3200")),
)
@@ -321,6 +322,7 @@ def _extract_ai_json_object(raw_text: str) -> Dict[str, Any]:
def _scan_ai_cache_key(snapshot_id: str, filters: Dict[str, Any]) -> str:
raw = json.dumps(
{
"schema_version": "city_forecast_v1",
"snapshot_id": snapshot_id,
"filters": _normalize_scan_terminal_filters(filters),
"model": SCAN_AI_MODEL,
@@ -366,21 +368,20 @@ def _compact_ai_candidate(row: Dict[str, Any]) -> Dict[str, Any]:
"target_value": row.get("target_value"),
"target_threshold": row.get("target_threshold"),
"target_unit": row.get("target_unit"),
"model_probability": row.get("model_probability"),
"market_probability": row.get("market_probability"),
"model_event_probability": row.get("model_event_probability"),
"market_event_probability": row.get("market_event_probability"),
"yes_ask": row.get("yes_ask"),
"no_ask": row.get("no_ask"),
"ask": row.get("ask"),
"spread": row.get("spread"),
"quote_age_ms": row.get("quote_age_ms"),
"edge_percent": row.get("edge_percent"),
"kelly_fraction": row.get("kelly_fraction"),
"quarter_kelly": row.get("quarter_kelly"),
"final_score": row.get("final_score"),
"cluster_role": row.get("cluster_role"),
"model_cluster_sources": _compact_ai_model_sources(row),
"metar_context": row.get("metar_context") or {},
"window_phase": row.get("window_phase"),
"peak_window_label": row.get("peak_window_label"),
"minutes_until_peak_start": row.get("minutes_until_peak_start"),
"minutes_until_peak_end": row.get("minutes_until_peak_end"),
"trend_alignment": row.get("trend_alignment"),
"tradable": row.get("tradable"),
"accepting_orders": row.get("accepting_orders"),
@@ -424,15 +425,246 @@ def _compact_ai_model_sources(row: Dict[str, Any]) -> List[Dict[str, Any]]:
return sources[:12]
def _observation_sort_key(point: Dict[str, Any]) -> tuple[int, str]:
raw_time = str(point.get("time") or "").strip()
try:
parsed = datetime.fromisoformat(raw_time.replace("Z", "+00:00"))
return parsed.hour * 60 + parsed.minute, raw_time
except Exception:
pass
match = re.search(r"(\d{1,2}):(\d{2})", raw_time)
if match:
hour = max(0, min(23, int(match.group(1))))
minute = max(0, min(59, int(match.group(2))))
return hour * 60 + minute, raw_time
return 9999, raw_time
def _compact_observation_points(raw_points: Any, limit: int = 24) -> List[Dict[str, Any]]:
if not isinstance(raw_points, list):
return []
points: List[Dict[str, Any]] = []
for item in raw_points:
if isinstance(item, dict):
temp = _safe_float(item.get("temp"))
time_value = str(item.get("time") or item.get("obs_time") or item.get("time_label") or "").strip()
elif isinstance(item, (list, tuple)) and len(item) >= 2:
time_value = str(item[0] or "").strip()
temp = _safe_float(item[1])
else:
continue
if temp is None or not time_value:
continue
points.append({"time": time_value, "temp": temp})
sorted_points = sorted(points, key=_observation_sort_key)
return sorted_points[-max(1, int(limit)) :]
def _build_metar_decision_context(data: Dict[str, Any]) -> Dict[str, Any]:
today_obs = _compact_observation_points(data.get("metar_today_obs"), 36)
recent_obs = _compact_observation_points(data.get("metar_recent_obs"), 12)
settlement_obs = _compact_observation_points(data.get("settlement_today_obs"), 36)
airport_current = data.get("airport_current") if isinstance(data.get("airport_current"), dict) else {}
metar_status = data.get("metar_status") if isinstance(data.get("metar_status"), dict) else {}
source_obs = today_obs or recent_obs or settlement_obs
trend_source = recent_obs or source_obs[-4:]
last_point = source_obs[-1] if source_obs else {}
first_trend = trend_source[0] if trend_source else {}
last_trend = trend_source[-1] if trend_source else {}
max_point = None
for point in source_obs:
if max_point is None or float(point["temp"]) >= float(max_point["temp"]):
max_point = point
last_temp = _safe_float(last_point.get("temp"))
first_temp = _safe_float(first_trend.get("temp"))
trend_last_temp = _safe_float(last_trend.get("temp"))
trend_delta = (
trend_last_temp - first_temp
if trend_last_temp is not None and first_temp is not None and len(trend_source) >= 2
else None
)
station = data.get("risk") if isinstance(data.get("risk"), dict) else {}
return {
"source": "METAR",
"station": station.get("icao") or airport_current.get("station_code"),
"station_label": station.get("airport") or airport_current.get("station_label"),
"today_obs": today_obs[-12:],
"recent_obs": recent_obs[-8:],
"settlement_today_obs": settlement_obs[-12:],
"obs_count": len(source_obs),
"last_time": last_point.get("time"),
"last_temp": last_temp,
"max_temp": _safe_float((max_point or {}).get("temp")),
"max_time": (max_point or {}).get("time"),
"trend_delta": trend_delta,
"stale_for_today": bool(metar_status.get("stale_for_today")),
"available_for_today": bool(metar_status.get("available_for_today")),
"last_observation_time": metar_status.get("last_observation_time"),
"airport_current_temp": _safe_float(airport_current.get("temp")),
"airport_max_so_far": _safe_float(airport_current.get("max_so_far")),
"airport_obs_time": airport_current.get("obs_time"),
"airport_report_time": airport_current.get("report_time"),
"airport_raw_metar": airport_current.get("raw_metar"),
"airport_wx_desc": airport_current.get("wx_desc"),
"airport_cloud_desc": airport_current.get("cloud_desc"),
"airport_visibility_mi": _safe_float(airport_current.get("visibility_mi")),
"airport_wind_speed_kt": _safe_float(airport_current.get("wind_speed_kt")),
"airport_wind_dir": _safe_float(airport_current.get("wind_dir")),
"airport_humidity": _safe_float(airport_current.get("humidity")),
}
def _target_range_from_row(row: Dict[str, Any]) -> tuple[Optional[float], Optional[float]]:
lower = _safe_float(row.get("target_lower"))
upper = _safe_float(row.get("target_upper"))
if lower is not None or upper is not None:
return lower, upper
threshold = _safe_float(row.get("target_threshold"))
target_value = _safe_float(row.get("target_value"))
raw_label = str(row.get("target_label") or row.get("action") or "")
numbers = [float(match.group(0)) for match in re.finditer(r"-?\d+(?:\.\d+)?", raw_label)]
if len(numbers) >= 2:
return min(numbers[0], numbers[1]), max(numbers[0], numbers[1])
value = threshold if threshold is not None else target_value if target_value is not None else (numbers[0] if numbers else None)
if value is None:
return None, None
if re.search(r"(\+|above|higher|or\s+higher|>=|≥|以上)", raw_label, re.I):
return value, None
if re.search(r"(below|or\s+below|<=|≤|以下)", raw_label, re.I):
return None, value
return value, value
def _metar_gate_for_row(row: Dict[str, Any]) -> Optional[Dict[str, Any]]:
context = row.get("metar_context") if isinstance(row.get("metar_context"), dict) else {}
side = str(row.get("side") or "").strip().lower()
if side not in {"yes", "no"}:
return None
obs_count = _safe_int(context.get("obs_count"), 0)
if obs_count <= 0 or context.get("stale_for_today"):
return {
"decision": "downgrade",
"reason_zh": "V4 未拿到同日 METAR 实测,不能只凭 edge/Kelly 给出交易。",
"reason_en": "V4 has no same-day METAR observations, so edge/Kelly alone cannot drive a trade.",
}
lower, upper = _target_range_from_row(row)
max_temp = _safe_float(context.get("max_temp"))
last_temp = _safe_float(context.get("last_temp"))
trend_delta = _safe_float(context.get("trend_delta"))
if max_temp is None or (lower is None and upper is None):
return None
unit = str(row.get("target_unit") or row.get("temp_symbol") or "")
epsilon = 0.7 if "F" in unit.upper() else 0.4
phase = str(row.get("window_phase") or "").lower()
remaining = _safe_float(row.get("remaining_window_minutes"))
minutes_until_peak_start = _safe_float(row.get("minutes_until_peak_start"))
is_late = phase in {"active_peak", "post_peak"} or (remaining is not None and remaining <= 180)
is_before_peak = phase in {"early_today", "setup_today", "tomorrow", "week_ahead"} or (
minutes_until_peak_start is not None and minutes_until_peak_start > 0
)
is_falling = trend_delta is not None and trend_delta <= -epsilon
is_not_rising = trend_delta is not None and trend_delta <= epsilon
above_upper = upper is not None and max_temp > upper + epsilon
below_lower = lower is not None and max_temp < lower - epsilon
inside_bucket = (
(lower is None or max_temp >= lower - epsilon)
and (upper is None or max_temp <= upper + epsilon)
)
if side == "no":
if above_upper:
return {
"decision": "approve",
"reason_zh": "METAR 实测最高已越过目标桶上沿,V4 确认 BUY NO 有实测支撑。",
"reason_en": "METAR max has already moved above the bucket, so V4 confirms BUY NO has observation support.",
}
if below_lower and (is_late or is_falling or is_not_rising):
if is_before_peak and not is_late:
return {
"decision": "watchlist",
"reason_zh": "峰值窗口尚未到来,METAR 暂未触达不能直接确认 BUY NO,V4 先列观察。",
"reason_en": "The peak window has not arrived, so a still-low METAR path cannot confirm BUY NO yet; V4 keeps it on watch.",
}
return {
"decision": "approve",
"reason_zh": "METAR 最高仍低于目标桶且近期走势不强,V4 确认 BUY NO 优先。",
"reason_en": "METAR max remains below the bucket and recent observations are not strengthening, so V4 favors BUY NO.",
}
if inside_bucket and is_late and is_not_rising:
return {
"decision": "downgrade",
"reason_zh": "METAR 最高仍贴近目标桶,V4 不允许只因 edge 高就直接交易 NO。",
"reason_en": "METAR max is still close to the target bucket, so V4 will not trade NO on edge alone.",
}
else:
if above_upper:
return {
"decision": "veto",
"reason_zh": "METAR 实测最高已越过目标桶上沿,V4 排除该 BUY YES。",
"reason_en": "METAR max has already exceeded the bucket, so V4 vetoes this BUY YES.",
}
if below_lower and (is_late or is_falling or is_not_rising):
if is_before_peak and not is_late:
return {
"decision": "watchlist",
"reason_zh": "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,V4 暂列观察。",
"reason_en": "The peak window has not arrived, so METAR not reaching the bucket only means the setup still needs peak-window confirmation; V4 keeps it on watch.",
}
return {
"decision": "downgrade",
"reason_zh": "METAR 最高仍未触达目标桶且走势不强,V4 将 BUY YES 降级观察。",
"reason_en": "METAR max has not reached the bucket and recent observations are weak, so V4 downgrades BUY YES.",
}
if inside_bucket:
return {
"decision": "approve",
"reason_zh": "METAR 实测最高已落入目标桶,V4 认为 BUY YES 有实测依据,但仍需防止继续升穿上沿。",
"reason_en": "METAR max is inside the target bucket, so V4 sees observation support for BUY YES while monitoring an overshoot.",
}
if last_temp is not None and trend_delta is not None:
direction = "走弱" if trend_delta < -epsilon else "走强" if trend_delta > epsilon else "横盘"
return {
"decision": "watchlist",
"reason_zh": f"METAR 最新 {last_temp:.1f},近期{direction}V4 暂不把该合约升级为最终交易。",
"reason_en": f"Latest METAR is {last_temp:.1f} with a recent {'downtrend' if trend_delta < -epsilon else 'uptrend' if trend_delta > epsilon else 'flat trend'}, so V4 keeps this as watchlist.",
}
return None
def _apply_metar_gate_to_row(row: Dict[str, Any]) -> None:
gate = _metar_gate_for_row(row)
if not gate:
return
decision = str(gate.get("decision") or "").lower()
row["v4_metar_decision"] = decision
row["v4_metar_reason_zh"] = gate.get("reason_zh")
row["v4_metar_reason_en"] = gate.get("reason_en")
current_decision = str(row.get("ai_decision") or "").lower()
hard_decisions = {"veto", "downgrade"}
if decision == "veto":
row["ai_decision"] = "veto"
row.pop("ai_rank", None)
elif decision == "downgrade" and current_decision != "veto":
row["ai_decision"] = "downgrade"
row.pop("ai_rank", None)
elif decision == "approve" and current_decision not in hard_decisions:
row["ai_decision"] = "approve"
elif decision == "watchlist" and current_decision not in {"approve", "veto", "downgrade"}:
row["ai_decision"] = "watchlist"
if decision in {"approve", "veto", "downgrade"}:
row["ai_reason_zh"] = gate.get("reason_zh") or row.get("ai_reason_zh")
row["ai_reason_en"] = gate.get("reason_en") or row.get("ai_reason_en")
def _compact_ai_city_group(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
first = rows[0]
distribution = _compact_ai_distribution(first)
peak = next((item for item in distribution if item.get("highlighted")), None)
if not peak and distribution:
peak = max(
distribution,
key=lambda item: _safe_float(item.get("model_probability")) or -1.0,
)
return {
"city": first.get("city"),
"city_display_name": first.get("city_display_name") or first.get("display_name") or first.get("city"),
@@ -444,12 +676,10 @@ def _compact_ai_city_group(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
"deb_prediction": first.get("deb_prediction"),
"window_phase": first.get("window_phase"),
"remaining_window_minutes": first.get("remaining_window_minutes"),
"emos_distribution": distribution,
"emos_peak": {
"label": (peak or {}).get("label"),
"value": (peak or {}).get("value"),
"probability": (peak or {}).get("model_probability"),
},
"peak_window_label": first.get("peak_window_label"),
"minutes_until_peak_start": first.get("minutes_until_peak_start"),
"minutes_until_peak_end": first.get("minutes_until_peak_end"),
"metar_context": first.get("metar_context") or {},
"model_cluster": {
"core_low": first.get("cluster_core_low"),
"core_high": first.get("cluster_core_high"),
@@ -480,7 +710,7 @@ def _build_scan_ai_prompt(payload: Dict[str, Any]) -> Dict[str, Any]:
cities = [_compact_ai_city_group(rows) for rows in grouped.values() if rows]
sent_contracts = sum(len(city.get("contracts") or []) for city in cities)
return {
"schema_version": "city_group_v2",
"schema_version": "city_forecast_v1",
"snapshot_id": payload.get("snapshot_id"),
"generated_at": payload.get("generated_at"),
"summary": payload.get("summary") or {},
@@ -501,28 +731,33 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured")
system_prompt = (
"你是 PolyWeather 的付费 V4-Flash 市场扫描员。你只能基于用户提供的 JSON 快照做判断,"
"不得编造城市、价格、概率、盘口或天气数据。输入已经按城市分组,每城包含 EMOS 分布"
"DEB、模型集群、当前实测和候选合约。你的任务不是复述 edge,而是形成 city thesis"
"再决定哪些合约值得推荐、哪些必须排除、哪些只降级观察"
"重点规则:最高温市场一天只会落入一个桶;如果 DEB/模型集群/EMOS 主峰集中在更高温区,"
"低温 YES 即使有表面 edge 也通常应 veto,优先考虑相邻尾部 NO;若价格过期、盘口太宽或窗口不支持,也要降级。"
"任何基于 edge 或 Kelly/仓位的推荐,都必须先参考该城市全部 model_cluster.sources、DEB 和 EMOS 分布;"
"不得只凭单一 EMOS 概率、单一模型或表面 edge 推荐"
"只能引用输入中的 row id。必须输出 JSON object。"
"你是 PolyWeather 的付费 V4-Flash 城市最高温预测员。你只能基于用户提供的 JSON 快照做判断,"
"不得编造城市、价格、概率、盘口或天气数据。输入已经按城市分组,每城包含 DEB"
"多个天气模型预测值 model_cluster.sources、METAR 实测序列、机场原始报文和候选合约。"
"你的首要任务不是分析套利,也不是推荐 BUY YES/NO,而是预测该城市今日最终最高温是多少"
"必须输出城市级最高温点估计、置信区间、置信度、峰值窗口状态、机场报文解读和一句预测理由。"
"V4 禁止使用 EMOS、EMOS peak、EMOS probability、edge 或 Kelly 作为交易依据;"
"最高温预测必须直接参考该城市全部 model_cluster.sources、DEB、峰值窗口和 METAR/机场报文。"
"如果天气模型之间分歧大,必须放宽置信区间并降低 confidence;如果 METAR 与模型路径冲突,必须解释修正方向"
"必须先判断 peak_window_label、minutes_until_peak_start/end 和 window_phase:峰值窗口尚未到来时,"
"不能因为 METAR 暂未触达目标温度就下最终结论,只能说明仍需峰值窗口验证;"
"必须检查 metar_context 的 today_obs/recent_obs、max_temp、last_temp、trend_delta、"
"airport_raw_metar、airport_wx_desc、airport_cloud_desc、airport_wind_* 和 stale 状态;"
"合约只作为下游映射:可以为每个候选 row_id 给出 forecast_matchcore/edge/outside/watch)和一句原因,"
"但不要输出交易建议,不要使用套利、仓位、edge 或 Kelly 语言。必须输出 JSON object。"
)
model_snapshot = dict(ai_input)
model_snapshot.pop("_polyweather_input_meta", None)
user_payload = {
"task": (
"Analyze each city first, then contract decisions. Return strict JSON only with: "
"summary_zh, summary_en, city_theses, recommendations, vetoed, downgraded, watchlist. "
"city_theses items need city, thesis_zh, thesis_en, model_cluster_note, confidence, "
"recommended_row_ids, vetoed_row_ids. recommendations items need row_id, rank, decision, "
"confidence, reason_zh, reason_en, model_cluster_note. vetoed/downgraded items need row_id, "
"reason_zh/reason_en. watchlist items are optional and need row_id plus reason. "
"If a recommendation cites edge or Kelly, the reason must mention the full model cluster consensus or divergence. "
"Keep every thesis and reason concise: one sentence only, no markdown, no repeated data tables."
"Return strict JSON only with: summary_zh, summary_en, city_forecasts, contract_notes. "
"city_forecasts items require city, predicted_max, range_low, range_high, unit, confidence, "
"peak_window_zh, peak_window_en, metar_read_zh, metar_read_en, reasoning_zh, reasoning_en, model_cluster_note. "
"contract_notes items are optional and require row_id, forecast_match, reason_zh, reason_en; "
"forecast_match must be one of core, edge, outside, watch. "
"Focus on final max temperature prediction; do not output recommendations/vetoed/downgraded unless needed for backward compatibility. "
"Do not mention EMOS, edge, Kelly, arbitrage, position size, or trading recommendation. "
"Keep every city forecast concise: one sentence for METAR read and one sentence for reasoning."
),
"snapshot": model_snapshot,
}
@@ -598,6 +833,43 @@ def _normalize_ai_city_theses(raw_items: Any) -> List[Dict[str, Any]]:
return out
def _normalize_ai_city_forecasts(ai_raw: Dict[str, Any]) -> List[Dict[str, Any]]:
raw_items = (
ai_raw.get("city_forecasts")
or ai_raw.get("city_predictions")
or ai_raw.get("city_max_forecasts")
or ai_raw.get("city_theses")
)
if not isinstance(raw_items, list):
return []
out: List[Dict[str, Any]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
city = str(item.get("city") or item.get("city_name") or "").strip()
if not city:
continue
predicted = (
item.get("predicted_max")
if item.get("predicted_max") is not None
else item.get("max_temp")
if item.get("max_temp") is not None
else item.get("prediction")
)
out.append(
{
**item,
"city": city,
"predicted_max": predicted,
"range_low": item.get("range_low") if item.get("range_low") is not None else item.get("low"),
"range_high": item.get("range_high") if item.get("range_high") is not None else item.get("high"),
"reasoning_zh": item.get("reasoning_zh") or item.get("thesis_zh") or item.get("summary_zh"),
"reasoning_en": item.get("reasoning_en") or item.get("thesis_en") or item.get("summary_en"),
}
)
return out
def _merge_scan_ai_result(
payload: Dict[str, Any],
ai_raw: Dict[str, Any],
@@ -613,6 +885,8 @@ def _merge_scan_ai_result(
downgraded = _normalize_ai_items(ai_raw.get("downgraded"))
watchlist = _normalize_ai_items(ai_raw.get("watchlist"))
city_theses = _normalize_ai_city_theses(ai_raw.get("city_theses"))
city_forecasts = _normalize_ai_city_forecasts(ai_raw)
contract_notes = _normalize_ai_items(ai_raw.get("contract_notes"))
veto_ids = {str(item.get("row_id")) for item in vetoed}
downgrade_ids = {str(item.get("row_id")) for item in downgraded}
@@ -624,17 +898,45 @@ def _merge_scan_ai_result(
key = _normalize_ai_city_key(item.get("city"))
if key:
thesis_by_city[key] = item
forecast_by_city: Dict[str, Dict[str, Any]] = {}
for item in city_forecasts:
key = _normalize_ai_city_key(item.get("city"))
if key:
forecast_by_city[key] = item
for row in rows:
thesis = thesis_by_city.get(_normalize_ai_city_key(row.get("city"))) or thesis_by_city.get(
_normalize_ai_city_key(row.get("city_display_name"))
)
if not thesis:
city_key = _normalize_ai_city_key(row.get("city"))
display_key = _normalize_ai_city_key(row.get("city_display_name"))
thesis = thesis_by_city.get(city_key) or thesis_by_city.get(display_key)
forecast = forecast_by_city.get(city_key) or forecast_by_city.get(display_key)
if thesis:
row["ai_city_thesis_zh"] = thesis.get("thesis_zh") or thesis.get("summary_zh")
row["ai_city_thesis_en"] = thesis.get("thesis_en") or thesis.get("summary_en")
row["ai_city_confidence"] = thesis.get("confidence")
row["ai_city_model_cluster_note"] = thesis.get("model_cluster_note")
if forecast:
row["ai_predicted_max"] = _safe_float(forecast.get("predicted_max"))
row["ai_predicted_low"] = _safe_float(forecast.get("range_low"))
row["ai_predicted_high"] = _safe_float(forecast.get("range_high"))
row["ai_forecast_unit"] = forecast.get("unit") or row.get("temp_symbol")
row["ai_forecast_confidence"] = forecast.get("confidence")
row["ai_peak_window_zh"] = forecast.get("peak_window_zh")
row["ai_peak_window_en"] = forecast.get("peak_window_en")
row["ai_airport_metar_read_zh"] = forecast.get("metar_read_zh")
row["ai_airport_metar_read_en"] = forecast.get("metar_read_en")
row["ai_forecast_reason_zh"] = forecast.get("reasoning_zh")
row["ai_forecast_reason_en"] = forecast.get("reasoning_en")
row["ai_city_model_cluster_note"] = forecast.get("model_cluster_note") or row.get("ai_city_model_cluster_note")
row["ai_city_thesis_zh"] = row.get("ai_city_thesis_zh") or forecast.get("reasoning_zh")
row["ai_city_thesis_en"] = row.get("ai_city_thesis_en") or forecast.get("reasoning_en")
for item in contract_notes:
row = by_id.get(str(item.get("row_id")))
if not row:
continue
row["ai_city_thesis_zh"] = thesis.get("thesis_zh") or thesis.get("summary_zh")
row["ai_city_thesis_en"] = thesis.get("thesis_en") or thesis.get("summary_en")
row["ai_city_confidence"] = thesis.get("confidence")
row["ai_city_model_cluster_note"] = thesis.get("model_cluster_note")
row["ai_forecast_match"] = item.get("forecast_match") or item.get("match")
row["ai_forecast_match_reason_zh"] = item.get("reason_zh") or item.get("reason")
row["ai_forecast_match_reason_en"] = item.get("reason_en")
for item in vetoed:
row = by_id.get(str(item.get("row_id")))
@@ -677,6 +979,7 @@ def _merge_scan_ai_result(
row["ai_decision"] = row.get("ai_decision") or "neutral"
if row_id in watchlist_ids and row.get("ai_decision") == "neutral":
row["ai_decision"] = "watchlist"
_apply_metar_gate_to_row(row)
def _ai_sort_key(row: Dict[str, Any]) -> tuple:
decision = str(row.get("ai_decision") or "").lower()
@@ -721,6 +1024,8 @@ def _merge_scan_ai_result(
"base_url": SCAN_AI_BASE_URL,
"summary_zh": ai_raw.get("summary_zh"),
"summary_en": ai_raw.get("summary_en"),
"city_forecasts": city_forecasts,
"contract_notes": contract_notes,
"city_theses": city_theses,
"watchlist": watchlist,
"recommended_count": sum(1 for row in rows if row.get("ai_rank") is not None),
@@ -834,6 +1139,7 @@ def _build_terminal_row(
city_meta = CITIES.get(city) or {}
tz_offset = _safe_int(city_meta.get("tz"), 0)
market_region = _market_region_from_tz_offset(tz_offset)
metar_context = _build_metar_decision_context(data)
return {
**row,
@@ -850,6 +1156,16 @@ def _build_terminal_row(
"temp_symbol": data.get("temp_symbol"),
"current_temp": current.get("temp"),
"current_max_so_far": current.get("max_so_far"),
"metar_context": metar_context,
"metar_today_obs": metar_context.get("today_obs") or [],
"metar_recent_obs": metar_context.get("recent_obs") or [],
"settlement_today_obs": metar_context.get("settlement_today_obs") or [],
"metar_status": {
"available_for_today": metar_context.get("available_for_today"),
"stale_for_today": metar_context.get("stale_for_today"),
"last_observation_time": metar_context.get("last_observation_time"),
"last_temp": metar_context.get("last_temp"),
},
"deb_prediction": ((daily_entry.get("deb") or {}).get("prediction") if isinstance(daily_entry.get("deb"), dict) else None)
or ((data.get("deb") or {}).get("prediction") if isinstance(data.get("deb"), dict) else None),
"display_name": display_name,