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 (
) : null} -
-
-

- {locale === "en-US" ? "Trade-Sensitive Metrics" : "交易关键指标"} -

-
- {locale === "en-US" - ? "Only the surface readings that still matter" - : "只保留仍会影响峰值判断的近地面项"} -
-
-
- {tradeMetricSummary} -
-
- {tradeMetricVisuals.map((metric) => ( -
-
- {metric.label} - - {metric.hint} - -
- {metric.value} -
-
-
-
- ))} -
-
- -
-
-

- {locale === "en-US" ? "Market Alignment" : "市场对照"} -

-
- {locale === "en-US" - ? "Model, tape, and execution in one view" - : "把模型、盘口和执行价放到一张卡里"} -
-
-
- {/* Loading Overlay */} - {store.loadingState.marketScan && ( -
-
- {locale === "en-US" - ? "Crunching Polymarket Edges..." - : "正在计算市场对手盘..."} -
- )} - - {/* Layer 1: Target & Edge */} -
-
- - {locale === "en-US" - ? "Target Bucket:" - : "结算温度区间:"} - - - {settlementBucketLabel} - -
-
-
- - {locale === "en-US" ? "Current edge" : "当前 edge"} - - 0 - ? "positive" - : "negative", - )} - > - {marketEdge} - -
-
-
- - {locale === "en-US" ? "Model prob" : "模型概率"} - - {modelProbability} -
-
- - {locale === "en-US" ? "Market mid" : "市场中位"} - - {marketMidpoint} -
-
-
-
- -
-
- 💸 {locale === "en-US" ? "Execution" : "盘口执行"} -
-
- - {locale === "en-US" ? "Yes bid / ask" : "Yes 买 / 卖"} - -
- {marketYesBuy} - / {marketYesSell} -
-
-
- - {locale === "en-US" ? "No bid / ask" : "No 买 / 卖"} - -
- {marketNoBuy} - / {marketNoSell} -
-
-
- - {locale === "en-US" ? "Spread" : "价差"} - -
- {marketSpread} -
-
-
- - {/* Layer 3: Context */} -
-
- 👀 {locale === "en-US" ? "Market Radar" : "情绪雷达"} -
-
- - {locale === "en-US" - ? "Top Volume Bucket:" - : "市场当前押注最热:"} - - - {hottestBucketLabel}{" "} - {hottestBucketProb !== "--" - ? `(${hottestBucketProb})` - : ""} - -
-
-
-
- {locale === "en-US" ? "Signal" : "信号"}:{" "} - {marketSignal} -
-
- {marketExecutionSummary} -
-
- -
-

- {locale === "en-US" - ? "Trade Snapshot" - : "交易快照"} -

-
-
- {locale === "en-US" ? "Day high so far" : "日内已见高点"} - - {topObservedTemp ?? "--"} - {detail.temp_symbol} - -
-
- {locale === "en-US" ? "Current obs" : "当前观测"} - {currentTempText} -
-
- {locale === "en-US" ? "Market signal" : "市场信号"} - {marketSignal} -
-
- {locale === "en-US" ? "Current edge" : "当前 edge"} - {marketEdge} -
-
-
@@ -1947,18 +1453,11 @@ export function FutureForecastModal() { {Math.round(view.front.precipMax)}%
- {todayTradeSummaryLines.length > 0 || view.front.summary ? ( + {todayTradeSummaryLines.length > 0 ? (
- {[ - ...todayTradeSummaryLines, - ...(Array.isArray(view.front.summaryLines) - ? view.front.summaryLines - : view.front.summary - ? [view.front.summary] - : []), - ].map((line, index) => ( -
{line}
- ))} + {todayTradeSummaryLines.map((line, index) => ( +
{line}
+ ))}
) : null} diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index ac4a6657..84474b9d 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -407,6 +407,11 @@ export interface CityDetail { dynamic_commentary?: { summary?: string | null; notes?: string[] | null; + headline_zh?: string | null; + headline_en?: string | null; + bullets_zh?: string[] | null; + bullets_en?: string[] | null; + source?: string | null; }; taf?: { source?: string | null; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 85993d55..ca5a5182 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -44,6 +44,32 @@ function containsCjk(text: string) { return /[\u3400-\u9fff]/.test(text); } +function getLocalizedDynamicCommentary( + detail: CityDetail, + locale: Locale, +): { headline: string; bullets: string[]; source: string } { + const commentary = detail.dynamic_commentary || {}; + const preferEnglish = isEnglish(locale); + const rawHeadline = preferEnglish + ? String(commentary.headline_en || "").trim() + : String(commentary.headline_zh || "").trim(); + const rawBullets = preferEnglish + ? commentary.bullets_en + : commentary.bullets_zh; + const bullets = Array.isArray(rawBullets) + ? rawBullets.map((item) => String(item || "").trim()).filter(Boolean) + : []; + const fallbackHeadline = String(commentary.summary || "").trim(); + const fallbackBullets = Array.isArray(commentary.notes) + ? commentary.notes.map((item) => String(item || "").trim()).filter(Boolean) + : []; + return { + headline: rawHeadline || fallbackHeadline, + bullets: bullets.length > 0 ? bullets : fallbackBullets, + source: String(commentary.source || "").trim(), + }; +} + function isTurkishMgmCity(detail: CityDetail) { const city = String(detail.name || detail.display_name || "") .trim() @@ -1327,21 +1353,16 @@ export function computeFrontTrendSignal( tone?: string; value: string; }> = []; - const rawBackendSummary = + const localizedCommentary = dateStr === detail.local_date - ? String(detail.dynamic_commentary?.summary || "").trim() - : ""; + ? getLocalizedDynamicCommentary(detail, locale) + : { headline: "", bullets: [], source: "" }; const backendSummary = - rawBackendSummary && - (!isEnglish(locale) || !containsCjk(rawBackendSummary)) - ? rawBackendSummary + localizedCommentary.headline && + (!isEnglish(locale) || !containsCjk(localizedCommentary.headline)) + ? localizedCommentary.headline : ""; - const rawBackendNotes = Array.isArray(detail.dynamic_commentary?.notes) - ? detail.dynamic_commentary?.notes - ?.map((item) => String(item || "").trim()) - .filter(Boolean) || [] - : []; - const backendNotes = rawBackendNotes.filter( + const backendNotes = localizedCommentary.bullets.filter( (note) => !isEnglish(locale) || !containsCjk(note), ); const slice = getFutureSlice(detail, dateStr); diff --git a/web/analysis_service.py b/web/analysis_service.py index 7b4f2305..31f6cf94 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -1,11 +1,15 @@ from __future__ import annotations +import hashlib +import json +import os import re import time as _time import threading from datetime import datetime, timezone, timedelta from typing import Dict, Any, Optional +import httpx from fastapi import HTTPException from loguru import logger @@ -38,6 +42,11 @@ _ANALYSIS_CACHE_STATS: Dict[str, Any] = { "last_cache_miss_at": None, "last_city": None, } +_GROQ_COMMENTARY_CACHE_LOCK = threading.Lock() +_GROQ_COMMENTARY_CACHE: Dict[str, Dict[str, Any]] = {} +_GROQ_COMMENTARY_CACHE_TTL_SEC = int( + os.getenv("POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC", "1800") +) def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None: @@ -68,6 +77,209 @@ def get_analysis_cache_stats() -> Dict[str, Any]: return stats +def _groq_commentary_enabled() -> bool: + enabled = str( + os.getenv("POLYWEATHER_GROQ_COMMENTARY_ENABLED", "false") + ).strip().lower() + api_key = str(os.getenv("GROQ_API_KEY") or "").strip() + return enabled in {"1", "true", "yes", "on"} and bool(api_key) + + +def _clean_commentary_text(value: Any, *, limit: int = 240) -> str: + text = str(value or "").strip() + if not text: + return "" + text = re.sub(r"\s+", " ", text) + return text[:limit].strip() + + +def _build_groq_commentary_context(result: Dict[str, Any]) -> Dict[str, Any]: + dynamic = result.get("dynamic_commentary") or {} + vertical = result.get("vertical_profile_signal") or {} + taf_signal = ((result.get("taf") or {}).get("signal") or {}) if isinstance(result.get("taf"), dict) else {} + network = result.get("network_lead_signal") or {} + peak = result.get("peak") or {} + current = result.get("current") or {} + airport_primary = result.get("airport_primary") or {} + notes = dynamic.get("notes") if isinstance(dynamic.get("notes"), list) else [] + compact_notes = [_clean_commentary_text(item, limit=180) for item in notes] + compact_notes = [item for item in compact_notes if item][:4] + return { + "city": result.get("display_name") or result.get("name"), + "local_date": result.get("local_date"), + "local_time": result.get("local_time"), + "temp_symbol": result.get("temp_symbol"), + "current_temp": current.get("temp"), + "day_high_so_far": current.get("max_so_far"), + "airport_anchor_temp": airport_primary.get("temp"), + "airport_vs_network_delta": result.get("airport_vs_network_delta"), + "peak_hours": peak.get("hours") or [], + "peak_status": peak.get("status"), + "network_lead_status": network.get("status"), + "network_lead_note": _clean_commentary_text(network.get("note"), limit=180), + "rules_summary": _clean_commentary_text(dynamic.get("summary"), limit=260), + "rules_notes": compact_notes, + "upper_air_summary_zh": _clean_commentary_text(vertical.get("summary_zh"), limit=260), + "upper_air_summary_en": _clean_commentary_text(vertical.get("summary_en"), limit=260), + "taf_summary_zh": _clean_commentary_text(taf_signal.get("summary_zh"), limit=220), + "taf_summary_en": _clean_commentary_text(taf_signal.get("summary_en"), limit=220), + "taf_peak_window": _clean_commentary_text(taf_signal.get("peak_window"), limit=80), + } + + +def _normalize_groq_commentary_payload(payload: Dict[str, Any]) -> Dict[str, Any]: + def _headline(value: Any, fallback: str) -> str: + text = _clean_commentary_text(value, limit=90) + return text or fallback + + def _bullets(value: Any) -> list[str]: + items = value if isinstance(value, list) else [] + cleaned = [_clean_commentary_text(item, limit=120) for item in items] + cleaned = [item for item in cleaned if item] + return cleaned[:3] + + zh_headline = _headline(payload.get("headline_zh"), "结构信号以现有规则结论为主。") + en_headline = _headline(payload.get("headline_en"), "Structural read stays anchored to the existing rule-based signal.") + zh_bullets = _bullets(payload.get("bullets_zh")) + en_bullets = _bullets(payload.get("bullets_en")) + while len(zh_bullets) < 3: + zh_bullets.append("继续结合当前节奏、边界风险和峰值窗口判断。") + while len(en_bullets) < 3: + en_bullets.append("Keep the read anchored to pace, boundary risk, and the peak window.") + return { + "headline_zh": zh_headline, + "headline_en": en_headline, + "bullets_zh": zh_bullets[:3], + "bullets_en": en_bullets[:3], + "source": "groq", + } + + +def _request_groq_commentary(context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + api_key = str(os.getenv("GROQ_API_KEY") or "").strip() + if not api_key: + return None + model = str(os.getenv("POLYWEATHER_GROQ_COMMENTARY_MODEL") or "openai/gpt-oss-20b").strip() + timeout_sec = float(os.getenv("POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC", "8")) + payload = { + "model": model, + "temperature": 0.2, + "max_tokens": 400, + "messages": [ + { + "role": "system", + "content": ( + "You rewrite weather-market structure commentary. " + "Never invent facts. Use only the provided context. " + "Return concise bilingual output for a dashboard: " + "one headline and exactly three bullets in Chinese, and the same in English. " + "Keep every bullet actionable and short." + ), + }, + { + "role": "user", + "content": json.dumps(context, ensure_ascii=False), + }, + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "polyweather_structure_commentary", + "strict": True, + "schema": { + "type": "object", + "additionalProperties": False, + "properties": { + "headline_zh": {"type": "string"}, + "bullets_zh": { + "type": "array", + "items": {"type": "string"}, + "minItems": 3, + "maxItems": 3, + }, + "headline_en": {"type": "string"}, + "bullets_en": { + "type": "array", + "items": {"type": "string"}, + "minItems": 3, + "maxItems": 3, + }, + }, + "required": [ + "headline_zh", + "bullets_zh", + "headline_en", + "bullets_en", + ], + }, + }, + }, + } + with httpx.Client(timeout=timeout_sec) as client: + response = client.post( + "https://api.groq.com/openai/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + body = response.json() + content = ( + (((body.get("choices") or [{}])[0]).get("message") or {}).get("content") + if isinstance(body, dict) + else None + ) + if not content: + return None + try: + return _normalize_groq_commentary_payload(json.loads(str(content))) + except Exception: + logger.warning("Groq commentary returned non-JSON payload") + return None + + +def _maybe_enrich_dynamic_commentary_with_groq( + city: str, + result: Dict[str, Any], +) -> Dict[str, Any]: + dynamic = result.get("dynamic_commentary") or {} + if not _groq_commentary_enabled(): + return dynamic + if dynamic.get("headline_zh") and dynamic.get("bullets_zh"): + return dynamic + + context = _build_groq_commentary_context(result) + if not context.get("rules_summary") and not context.get("rules_notes"): + return dynamic + + cache_key = hashlib.sha256( + json.dumps({"city": city, "context": context}, sort_keys=True, ensure_ascii=False).encode("utf-8") + ).hexdigest() + now = _time.time() + with _GROQ_COMMENTARY_CACHE_LOCK: + cached = _GROQ_COMMENTARY_CACHE.get(cache_key) + if cached and now - float(cached.get("t") or 0) < _GROQ_COMMENTARY_CACHE_TTL_SEC: + merged = dict(dynamic) + merged.update(cached.get("payload") or {}) + return merged + + try: + enriched = _request_groq_commentary(context) + except Exception as exc: + logger.warning("Groq commentary skipped for {}: {}", city, exc) + return dynamic + if not enriched: + return dynamic + + with _GROQ_COMMENTARY_CACHE_LOCK: + _GROQ_COMMENTARY_CACHE[cache_key] = {"t": now, "payload": enriched} + merged = dict(dynamic) + merged.update(enriched) + return merged + + def _interpolate_hourly_value( times: list, values: list, @@ -811,7 +1023,11 @@ def _build_taf_signal( } -def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: +def _analyze( + city: str, + force_refresh: bool = False, + include_llm_commentary: bool = False, +) -> Dict[str, Any]: """Fetch, analyse, and return structured weather data for one city.""" # Check cache ttl = CACHE_TTL_ANKARA if city.lower() in TURKISH_MGM_CITIES else CACHE_TTL @@ -819,6 +1035,14 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: if not force_refresh: cached = _cache.get(city) if cached and _time.time() - cached["t"] < ttl: + if include_llm_commentary: + cached_payload = cached["d"] + dynamic = cached_payload.get("dynamic_commentary") or {} + if not dynamic.get("headline_zh"): + cached_payload["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq( + city, + cached_payload, + ) _record_analysis_cache_event(city=city, hit=True, force_refresh=False) return cached["d"] _record_analysis_cache_event(city=city, hit=False, force_refresh=force_refresh) @@ -1602,6 +1826,12 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: "updated_at": datetime.now(timezone.utc).isoformat(), } + if include_llm_commentary: + result["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq( + city, + result, + ) + _cache[city] = {"t": _time.time(), "d": result} return result diff --git a/web/routes.py b/web/routes.py index 12d98188..a94df910 100644 --- a/web/routes.py +++ b/web/routes.py @@ -1018,7 +1018,7 @@ async def payment_reconcile_latest(request: Request): @router.get("/api/city/{name}/summary") async def city_summary(request: Request, name: str, force_refresh: bool = False): city = _normalize_city_or_404(name) - data = await run_in_threadpool(_analyze, city, force_refresh) + data = await run_in_threadpool(_analyze, city, force_refresh, False) return await run_in_threadpool(_build_city_summary_payload, data) @@ -1032,7 +1032,7 @@ async def city_detail_aggregate( ): _assert_entitlement(request) city = _normalize_city_or_404(name) - data = await run_in_threadpool(_analyze, city, force_refresh) + data = await run_in_threadpool(_analyze, city, force_refresh, True) return await run_in_threadpool( _build_city_detail_payload, data,