From f0904dbcc5ea750b60edf2236938b063fd1f2b6c Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 8 Apr 2026 11:41:34 +0800 Subject: [PATCH] Add Groq commentary config and simplify forecast modal --- .env.example | 7 + .../dashboard/FutureForecastModal.tsx | 569 ++---------------- frontend/lib/dashboard-types.ts | 5 + frontend/lib/dashboard-utils.ts | 45 +- web/analysis_service.py | 232 ++++++- web/routes.py | 4 +- 6 files changed, 312 insertions(+), 550 deletions(-) diff --git a/.env.example b/.env.example index ab9b0389..18d65f54 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,13 @@ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com # 7) Optional modules ######################################## +# Optional Groq commentary rewrite for intraday structure cards +POLYWEATHER_GROQ_COMMENTARY_ENABLED=false +GROQ_API_KEY= +POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b +POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8 +POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800 + # Weekly reward / leaderboard POLYWEATHER_WEEKLY_REWARD_ENABLED=true POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index a1022699..be6c835a 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -19,7 +19,6 @@ import { useChart } from "@/hooks/useChart"; import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; -import { IntradaySignalScene } from "@/components/dashboard/IntradaySignalScene"; import { ModelForecast, ProbabilityDistribution, @@ -29,7 +28,6 @@ import { getModelView, getProbabilityView, getTodayPaceView, - parseAiAnalysis, getTemperatureChartData, getWeatherSummary, } from "@/lib/dashboard-utils"; @@ -63,14 +61,6 @@ function formatMarketPercent(value?: number | null) { return `${(normalized * 100).toFixed(1)}%`; } -function formatMarketPriceCents(value?: number | null) { - const normalized = normalizeMarketValue(value); - if (normalized == null) return "--"; - const cents = normalized * 100; - const rounded = Math.round(cents * 10) / 10; - return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`; -} - function formatSignedPercent(value?: number | null) { const numeric = Number(value); if (!Number.isFinite(numeric)) return "--"; @@ -78,26 +68,6 @@ function formatSignedPercent(value?: number | null) { return `${sign}${numeric.toFixed(1)}%`; } -function formatSpreadPercent(low?: number | null, high?: number | null) { - const a = normalizeMarketValue(low); - const b = normalizeMarketValue(high); - if (a == null || b == null) return "--"; - const spreadCents = Math.abs((b - a) * 100); - const rounded = Math.round(spreadCents * 10) / 10; - return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}¢`; -} - -function resolveCounterPrice( - directValue?: number | null, - mirrorValue?: number | null, -) { - const direct = normalizeMarketValue(directValue); - if (direct != null) return direct; - const mirror = normalizeMarketValue(mirrorValue); - if (mirror == null) return null; - return Math.max(0, Math.min(1, 1 - mirror)); -} - function formatBucketLabel( bucket?: { label?: string | null; @@ -128,62 +98,6 @@ function formatBucketLabel( return "--"; } -function parseMetarSignedInt(token: string) { - if (!token) return null; - const normalized = token.toUpperCase(); - if (!/^[M]?\d{2}$/.test(normalized)) return null; - const value = Number(normalized.replace("M", "")); - if (!Number.isFinite(value)) return null; - return normalized.startsWith("M") ? -value : value; -} - -function parseMetarTempDew(rawMetar?: string | null) { - const text = String(rawMetar || "").toUpperCase(); - if (!text) - return { tempC: null as number | null, dewC: null as number | null }; - const match = text.match(/\s(M?\d{2})\/(M?\d{2})(?:\s|$)/); - if (!match) - return { tempC: null as number | null, dewC: null as number | null }; - return { - tempC: parseMetarSignedInt(match[1]), - dewC: parseMetarSignedInt(match[2]), - }; -} - -function estimateHumidityFromTempDew( - tempC?: number | null, - dewC?: number | null, -) { - const t = Number(tempC); - const d = Number(dewC); - if (!Number.isFinite(t) || !Number.isFinite(d)) return null; - const es = Math.exp((17.625 * t) / (243.04 + t)); - const ed = Math.exp((17.625 * d) / (243.04 + d)); - const rh = (ed / es) * 100; - if (!Number.isFinite(rh)) return null; - return Math.max(0, Math.min(100, rh)); -} - -function parseVisibilityText( - rawMetar?: string | null, - visibilityMi?: number | null, -) { - const direct = Number(visibilityMi); - if (Number.isFinite(direct)) { - return `${direct} mi`; - } - - const text = String(rawMetar || "").toUpperCase(); - if (!text) return "--"; - if (text.includes("CAVOK")) return ">=6 mi"; - - const sm = text.match(/\s(\d{1,2}(?:\/\d)?)SM(?:\s|$)/); - if (sm) { - return `${sm[1]} mi`; - } - return "--"; -} - function parseBucketBoundaries( bucket?: { label?: string | null; @@ -238,15 +152,6 @@ function parseClockMinutes(value?: string | null) { return hours * 60 + minutes; } -function parseVisibilityMiles(value?: string | null) { - const text = String(value || "").trim(); - if (!text || text === "--") return null; - const match = text.match(/(\d+(?:\.\d+)?)/); - if (!match) return null; - const numeric = Number(match[1]); - return Number.isFinite(numeric) ? numeric : null; -} - function parseLeadingNumber(value?: string | number | null) { if (typeof value === "number" && Number.isFinite(value)) return value; const text = String(value || "").trim(); @@ -256,73 +161,6 @@ function parseLeadingNumber(value?: string | number | null) { return Number.isFinite(numeric) ? numeric : null; } -function getCurrentMetricDescriptor( - kind: "humidity" | "dewpoint" | "wind" | "visibility", - value: string, - numeric: number | null, - aux?: number | null, -) { - if (kind === "humidity") { - const percent = numeric != null ? clamp(numeric, 0, 100) : null; - const hint = - percent == null - ? "--" - : percent >= 75 - ? "偏湿" - : percent >= 45 - ? "适中" - : "偏干"; - const tone = - percent == null ? "neutral" : percent >= 75 ? "cyan" : percent >= 45 ? "blue" : "amber"; - return { fill: percent, hint, tone, value }; - } - - if (kind === "dewpoint") { - const spread = aux != null && numeric != null ? aux - numeric : null; - const closeness = - spread != null ? clamp(100 - spread * 12, 0, 100) : null; - const hint = - spread == null - ? "--" - : spread <= 2 - ? "近饱和" - : spread <= 6 - ? "偏湿" - : "偏干"; - const tone = - spread == null ? "neutral" : spread <= 2 ? "cyan" : spread <= 6 ? "blue" : "amber"; - return { fill: closeness, hint, tone, value }; - } - - if (kind === "wind") { - const percent = numeric != null ? clamp((numeric / 25) * 100, 0, 100) : null; - const hint = - numeric == null - ? "--" - : numeric >= 18 - ? "偏强" - : numeric >= 8 - ? "中等" - : "较弱"; - const tone = - numeric == null ? "neutral" : numeric >= 18 ? "amber" : numeric >= 8 ? "blue" : "cyan"; - return { fill: percent, hint, tone, value }; - } - - const percent = numeric != null ? clamp((numeric / 10) * 100, 0, 100) : null; - const hint = - numeric == null - ? "--" - : numeric >= 6 - ? "通透" - : numeric >= 3 - ? "一般" - : "受限"; - const tone = - numeric == null ? "neutral" : numeric >= 6 ? "cyan" : numeric >= 3 ? "blue" : "amber"; - return { fill: percent, hint, tone, value }; -} - function getTrendMetricVisual(metric: { label?: string; value?: string; @@ -913,23 +751,6 @@ export function FutureForecastModal() { String(detail.current?.station_name || "").trim() || String(detail.risk?.airport || "").trim() || noaaStationCode; - const marketMidpoint = formatMarketPercent( - marketScan?.market_price ?? marketScan?.yes_token?.implied_probability, - ); - const modelProbability = formatMarketPercent(marketScan?.model_probability); - const marketYesBuy = formatMarketPriceCents(marketScan?.yes_buy); - const marketYesSell = formatMarketPriceCents(marketScan?.yes_sell); - const marketNoBuy = formatMarketPriceCents( - resolveCounterPrice(marketScan?.no_buy, marketScan?.yes_buy), - ); - const marketNoSell = formatMarketPriceCents( - resolveCounterPrice(marketScan?.no_sell, marketScan?.yes_sell), - ); - const marketEdge = formatSignedPercent(marketScan?.edge_percent); - const marketSpread = formatSpreadPercent( - marketScan?.yes_buy, - marketScan?.yes_sell, - ); const topBucket = Array.isArray(marketScan?.top_buckets) ? [...marketScan.top_buckets] .map((item) => ({ @@ -950,36 +771,7 @@ export function FutureForecastModal() { ) .sort((a, b) => b.probability - a.probability)[0] : null; - const settlementBucketLabel = formatBucketLabel( - marketScan?.temperature_bucket, - ); const hottestBucketLabel = formatBucketLabel(topBucket); - const hottestBucketProb = formatMarketPercent(topBucket?.probability); - const marketSignal = marketScan?.signal_label - ? `${marketScan.signal_label}${ - marketScan.confidence ? ` / ${marketScan.confidence}` : "" - }` - : "--"; - const marketExecutionSummary = (() => { - if (!marketScan?.available) { - return locale === "en-US" - ? "Market scan is unavailable right now; keep the trade read anchored to station pace and bracket risk." - : "当前暂时拿不到市场扫描结果,先以站点节奏和边界风险为主。"; - } - if (marketScan?.signal_label?.toUpperCase() === "BUY YES") { - return locale === "en-US" - ? `Model side is hotter than the tape by ${marketEdge}. If pace stays constructive, the Yes side still has room.` - : `模型侧当前比盘口更热 ${marketEdge},如果盘中节奏不掉,Yes 侧仍有空间。`; - } - if (marketScan?.signal_label?.toUpperCase() === "BUY NO") { - return locale === "en-US" - ? `Model side is cooler than the tape by ${marketEdge}. If pace keeps lagging, the No side stays cleaner.` - : `模型侧当前比盘口更冷 ${marketEdge},如果节奏继续落后,No 侧会更干净。`; - } - return locale === "en-US" - ? "Model and market are broadly balanced; let pace and boundary risk break the tie." - : "模型和市场当前大体均衡,接下来主要看节奏和边界风险来决定站边。"; - })(); const probabilitySummary = (() => { if (!topProbabilityBucket) { return locale === "en-US" @@ -1008,8 +800,8 @@ export function FutureForecastModal() { const numericEdge = Number(marketScan?.edge_percent); const hottestMatchesSettlement = hottestBucketLabel !== "--" && - settlementBucketLabel !== "--" && - hottestBucketLabel === settlementBucketLabel; + formatBucketLabel(marketScan?.temperature_bucket) !== "--" && + hottestBucketLabel === formatBucketLabel(marketScan?.temperature_bucket); const marketAwareUpperAirCue = useMemo(() => { if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null; @@ -1155,19 +947,6 @@ export function FutureForecastModal() { upperAirSignal.heating_setup, upperAirSignal.source, ]); - const metarParsed = parseMetarTempDew(detail.current?.raw_metar); - const fallbackDewpoint = - detail.current?.dewpoint ?? - metarParsed.dewC ?? - (Array.isArray(detail.hourly_next_48h?.dew_point) - ? detail.hourly_next_48h?.dew_point?.[0] - : null); - const fallbackHumidity = - detail.current?.humidity ?? - estimateHumidityFromTempDew( - detail.current?.temp ?? metarParsed.tempC, - fallbackDewpoint, - ); const topObservedTemp = detail.current?.max_so_far != null ? detail.current.max_so_far @@ -1176,27 +955,6 @@ export function FutureForecastModal() { detail.current?.temp != null ? `${detail.current.temp}${detail.temp_symbol}` : "--"; - const humidityText = - fallbackHumidity != null ? `${Math.round(fallbackHumidity)}%` : "--"; - const dewpointText = - fallbackDewpoint != null - ? `${fallbackDewpoint}${detail.temp_symbol}` - : "--"; - const windText = - detail.current?.wind_speed_kt != null - ? `${detail.current.wind_speed_kt} kt` - : "--"; - const visibilityText = parseVisibilityText( - detail.current?.raw_metar, - detail.current?.visibility_mi, - ); - const humidityValue = - fallbackHumidity != null ? Math.round(fallbackHumidity) : null; - const windValue = - detail.current?.wind_speed_kt != null - ? Number(detail.current.wind_speed_kt) - : null; - const visibilityValue = parseVisibilityMiles(visibilityText); const daylightProgress = (() => { const now = parseClockMinutes(detail.current?.obs_time); const sunrise = parseClockMinutes(detail.forecast?.sunrise); @@ -1212,60 +970,6 @@ export function FutureForecastModal() { percent, }; })(); - const currentMetricVisuals = [ - { - key: "humidity", - label: locale === "en-US" ? "Humidity" : "湿度", - ...getCurrentMetricDescriptor("humidity", humidityText, humidityValue), - }, - { - key: "dewpoint", - label: locale === "en-US" ? "Dew Point" : "露点", - ...getCurrentMetricDescriptor( - "dewpoint", - dewpointText, - fallbackDewpoint != null ? Number(fallbackDewpoint) : null, - detail.current?.temp != null ? Number(detail.current.temp) : null, - ), - }, - { - key: "wind", - label: locale === "en-US" ? "Wind" : "风速", - ...getCurrentMetricDescriptor("wind", windText, windValue), - }, - { - key: "visibility", - label: locale === "en-US" ? "Visibility" : "能见度", - ...getCurrentMetricDescriptor("visibility", visibilityText, visibilityValue), - }, - ]; - const tradeMetricVisuals = currentMetricVisuals.filter((metric) => - ["dewpoint", "wind", "humidity"].includes(metric.key), - ); - const tradeMetricSummary = (() => { - const dewSpread = - detail.current?.temp != null && fallbackDewpoint != null - ? Number(detail.current.temp) - Number(fallbackDewpoint) - : null; - if (dewSpread != null && dewSpread <= 2) { - return locale === "en-US" - ? "Near-saturation air is still in play. If cloud builds into the peak window, upside can get capped quickly." - : "露点差已经很窄,空气接近饱和。如果云层在峰值窗口内长起来,上冲会更容易被压住。"; - } - if (windValue != null && windValue >= 18) { - return locale === "en-US" - ? "Surface wind is still strong. Mixing support is better, but it can also make the tape noisier intraday." - : "近地面风速仍偏强,混合作用会更充分,但盘中波动也会更大。"; - } - if (humidityValue != null && humidityValue <= 40) { - return locale === "en-US" - ? "Air mass is still relatively dry. Unless cloud/rain arrives, the surface layer is not the main cap right now." - : "空气仍偏干,除非后续云雨快速起来,否则近地面层目前不是主要压温来源。"; - } - return locale === "en-US" - ? "Surface readings are broadly neutral. Keep the main decision anchored to pace, boundary risk, and the peak window." - : "近地面指标整体偏中性,主判断仍然要看当前节奏、边界风险和峰值窗口。"; - })(); const displayedUpperAirSummary = marketAwareUpperAirCue?.summary || view.front.upperAirSummary; const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( @@ -1281,40 +985,56 @@ export function FutureForecastModal() { } : metric, ); + const localizedAiCommentaryLines = useMemo(() => { + const commentary = detail.dynamic_commentary || {}; + const headline = String( + locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "", + ).trim(); + const bullets = ( + locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh + ) as string[] | null | undefined; + const cleanedBullets = Array.isArray(bullets) + ? bullets.map((item) => String(item || "").trim()).filter(Boolean) + : []; + return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3); + }, [detail.dynamic_commentary, locale]); const todayTradeSummaryLines = useMemo(() => { if (!isToday) return [] as string[]; + if (localizedAiCommentaryLines.length > 0) { + return localizedAiCommentaryLines; + } const lines: string[] = []; if (paceView) { const headline = paceView.biasTone === "warm" ? locale === "en-US" - ? `Intraday pace is running hot by ${paceView.deltaText}; the day high still has room to lean above the base curve.` - : `日内节奏当前偏热 ${paceView.deltaText},日高仍有机会落在基础曲线之上。` + ? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.` + : `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。` : paceView.biasTone === "cold" ? locale === "en-US" - ? `Intraday pace is trailing by ${paceView.deltaText}; higher buckets need more caution.` - : `日内节奏当前落后 ${paceView.deltaText},继续追更高温区间要更谨慎。` + ? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.` + : `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。` : locale === "en-US" - ? "Intraday pace is still tracking the curve; the next move depends on the peak-window push." - : "日内节奏当前基本贴着曲线运行,下一步主要看峰值窗口内还有没有上冲。"; + ? "Pace is still on curve; the next move depends on the peak-window push." + : "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。"; lines.push(headline); } if (boundaryRiskView) { lines.push( locale === "en-US" - ? `${boundaryRiskView.label}: ${boundaryRiskView.status}. ${boundaryRiskView.note}` - : `${boundaryRiskView.label}:${boundaryRiskView.status}。${boundaryRiskView.note}`, + ? `${boundaryRiskView.label}: ${boundaryRiskView.note}` + : `${boundaryRiskView.label}:${boundaryRiskView.note}`, ); } if (networkLeadView) { lines.push( locale === "en-US" - ? `${networkLeadView.label}: ${networkLeadView.status}. ${networkLeadView.note}` - : `${networkLeadView.label}:${networkLeadView.status}。${networkLeadView.note}`, + ? `${networkLeadView.label}: ${networkLeadView.note}` + : `${networkLeadView.label}:${networkLeadView.note}`, ); } - return lines; - }, [boundaryRiskView, isToday, locale, networkLeadView, paceView]); + return lines.slice(0, 3); + }, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView]); return (