Add Groq commentary config and simplify forecast modal

This commit is contained in:
2569718930@qq.com
2026-04-08 11:41:34 +08:00
parent 1d937728ee
commit f0904dbcc5
6 changed files with 312 additions and 550 deletions
+7
View File
@@ -94,6 +94,13 @@ NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# 7) Optional modules # 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 # Weekly reward / leaderboard
POLYWEATHER_WEEKLY_REWARD_ENABLED=true POLYWEATHER_WEEKLY_REWARD_ENABLED=true
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
@@ -19,7 +19,6 @@ import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { IntradaySignalScene } from "@/components/dashboard/IntradaySignalScene";
import { import {
ModelForecast, ModelForecast,
ProbabilityDistribution, ProbabilityDistribution,
@@ -29,7 +28,6 @@ import {
getModelView, getModelView,
getProbabilityView, getProbabilityView,
getTodayPaceView, getTodayPaceView,
parseAiAnalysis,
getTemperatureChartData, getTemperatureChartData,
getWeatherSummary, getWeatherSummary,
} from "@/lib/dashboard-utils"; } from "@/lib/dashboard-utils";
@@ -63,14 +61,6 @@ function formatMarketPercent(value?: number | null) {
return `${(normalized * 100).toFixed(1)}%`; 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) { function formatSignedPercent(value?: number | null) {
const numeric = Number(value); const numeric = Number(value);
if (!Number.isFinite(numeric)) return "--"; if (!Number.isFinite(numeric)) return "--";
@@ -78,26 +68,6 @@ function formatSignedPercent(value?: number | null) {
return `${sign}${numeric.toFixed(1)}%`; 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( function formatBucketLabel(
bucket?: { bucket?: {
label?: string | null; label?: string | null;
@@ -128,62 +98,6 @@ function formatBucketLabel(
return "--"; 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( function parseBucketBoundaries(
bucket?: { bucket?: {
label?: string | null; label?: string | null;
@@ -238,15 +152,6 @@ function parseClockMinutes(value?: string | null) {
return hours * 60 + minutes; 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) { function parseLeadingNumber(value?: string | number | null) {
if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) return value;
const text = String(value || "").trim(); const text = String(value || "").trim();
@@ -256,73 +161,6 @@ function parseLeadingNumber(value?: string | number | null) {
return Number.isFinite(numeric) ? numeric : 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: { function getTrendMetricVisual(metric: {
label?: string; label?: string;
value?: string; value?: string;
@@ -913,23 +751,6 @@ export function FutureForecastModal() {
String(detail.current?.station_name || "").trim() || String(detail.current?.station_name || "").trim() ||
String(detail.risk?.airport || "").trim() || String(detail.risk?.airport || "").trim() ||
noaaStationCode; 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) const topBucket = Array.isArray(marketScan?.top_buckets)
? [...marketScan.top_buckets] ? [...marketScan.top_buckets]
.map((item) => ({ .map((item) => ({
@@ -950,36 +771,7 @@ export function FutureForecastModal() {
) )
.sort((a, b) => b.probability - a.probability)[0] .sort((a, b) => b.probability - a.probability)[0]
: null; : null;
const settlementBucketLabel = formatBucketLabel(
marketScan?.temperature_bucket,
);
const hottestBucketLabel = formatBucketLabel(topBucket); 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 = (() => { const probabilitySummary = (() => {
if (!topProbabilityBucket) { if (!topProbabilityBucket) {
return locale === "en-US" return locale === "en-US"
@@ -1008,8 +800,8 @@ export function FutureForecastModal() {
const numericEdge = Number(marketScan?.edge_percent); const numericEdge = Number(marketScan?.edge_percent);
const hottestMatchesSettlement = const hottestMatchesSettlement =
hottestBucketLabel !== "--" && hottestBucketLabel !== "--" &&
settlementBucketLabel !== "--" && formatBucketLabel(marketScan?.temperature_bucket) !== "--" &&
hottestBucketLabel === settlementBucketLabel; hottestBucketLabel === formatBucketLabel(marketScan?.temperature_bucket);
const marketAwareUpperAirCue = useMemo(() => { const marketAwareUpperAirCue = useMemo(() => {
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null; if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
@@ -1155,19 +947,6 @@ export function FutureForecastModal() {
upperAirSignal.heating_setup, upperAirSignal.heating_setup,
upperAirSignal.source, 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 = const topObservedTemp =
detail.current?.max_so_far != null detail.current?.max_so_far != null
? detail.current.max_so_far ? detail.current.max_so_far
@@ -1176,27 +955,6 @@ export function FutureForecastModal() {
detail.current?.temp != null detail.current?.temp != null
? `${detail.current.temp}${detail.temp_symbol}` ? `${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 daylightProgress = (() => {
const now = parseClockMinutes(detail.current?.obs_time); const now = parseClockMinutes(detail.current?.obs_time);
const sunrise = parseClockMinutes(detail.forecast?.sunrise); const sunrise = parseClockMinutes(detail.forecast?.sunrise);
@@ -1212,60 +970,6 @@ export function FutureForecastModal() {
percent, 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 = const displayedUpperAirSummary =
marketAwareUpperAirCue?.summary || view.front.upperAirSummary; marketAwareUpperAirCue?.summary || view.front.upperAirSummary;
const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map(
@@ -1281,40 +985,56 @@ export function FutureForecastModal() {
} }
: metric, : 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(() => { const todayTradeSummaryLines = useMemo(() => {
if (!isToday) return [] as string[]; if (!isToday) return [] as string[];
if (localizedAiCommentaryLines.length > 0) {
return localizedAiCommentaryLines;
}
const lines: string[] = []; const lines: string[] = [];
if (paceView) { if (paceView) {
const headline = const headline =
paceView.biasTone === "warm" paceView.biasTone === "warm"
? locale === "en-US" ? locale === "en-US"
? `Intraday pace is running hot by ${paceView.deltaText}; the day high still has room to lean above the base curve.` ? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.`
: `日内节奏当前偏热 ${paceView.deltaText},日高仍有机会落在基础曲线之上。` : `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。`
: paceView.biasTone === "cold" : paceView.biasTone === "cold"
? locale === "en-US" ? locale === "en-US"
? `Intraday pace is trailing by ${paceView.deltaText}; higher buckets need more caution.` ? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.`
: `日内节奏当前落后 ${paceView.deltaText},继续追更高温区间要更谨慎。` : `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。`
: locale === "en-US" : 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); lines.push(headline);
} }
if (boundaryRiskView) { if (boundaryRiskView) {
lines.push( lines.push(
locale === "en-US" locale === "en-US"
? `${boundaryRiskView.label}: ${boundaryRiskView.status}. ${boundaryRiskView.note}` ? `${boundaryRiskView.label}: ${boundaryRiskView.note}`
: `${boundaryRiskView.label}${boundaryRiskView.status}${boundaryRiskView.note}`, : `${boundaryRiskView.label}${boundaryRiskView.note}`,
); );
} }
if (networkLeadView) { if (networkLeadView) {
lines.push( lines.push(
locale === "en-US" locale === "en-US"
? `${networkLeadView.label}: ${networkLeadView.status}. ${networkLeadView.note}` ? `${networkLeadView.label}: ${networkLeadView.note}`
: `${networkLeadView.label}${networkLeadView.status}${networkLeadView.note}`, : `${networkLeadView.label}${networkLeadView.note}`,
); );
} }
return lines; return lines.slice(0, 3);
}, [boundaryRiskView, isToday, locale, networkLeadView, paceView]); }, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView]);
return ( return (
<div <div
@@ -1642,220 +1362,6 @@ export function FutureForecastModal() {
</section> </section>
) : null} ) : null}
<section className="future-v2-card future-v2-support-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Trade-Sensitive Metrics" : "交易关键指标"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Only the surface readings that still matter"
: "只保留仍会影响峰值判断的近地面项"}
</div>
</div>
<div className="future-v2-pace-summary" style={{ marginTop: "12px" }}>
{tradeMetricSummary}
</div>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
{tradeMetricVisuals.map((metric) => (
<div key={metric.key} className="future-v2-mini-item future-v2-signal-item">
<div className="future-v2-signal-head">
<span>{metric.label}</span>
<em
className={clsx(
"future-v2-signal-tag",
metric.tone === "cyan" && "cyan",
metric.tone === "blue" && "blue",
metric.tone === "amber" && "amber",
)}
>
{metric.hint}
</em>
</div>
<strong>{metric.value}</strong>
<div className="future-v2-signal-meter">
<div
className={clsx(
"future-v2-signal-fill",
metric.tone === "cyan" && "cyan",
metric.tone === "blue" && "blue",
metric.tone === "amber" && "amber",
)}
style={{
width:
metric.fill != null ? `${metric.fill}%` : "18%",
}}
/>
</div>
</div>
))}
</div>
</section>
<section className="future-v2-card future-v2-market-card future-v2-focus-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Market Alignment" : "市场对照"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Model, tape, and execution in one view"
: "把模型、盘口和执行价放到一张卡里"}
</div>
</div>
<div className="future-v2-market-v3">
{/* Loading Overlay */}
{store.loadingState.marketScan && (
<div className="market-layer-loading-overlay">
<div
className="loading-spinner"
style={{
marginBottom: "8px",
width: "24px",
height: "24px",
borderWidth: "2px",
}}
/>
{locale === "en-US"
? "Crunching Polymarket Edges..."
: "正在计算市场对手盘..."}
</div>
)}
{/* Layer 1: Target & Edge */}
<div className="market-layer-target">
<div className="market-target-header">
<span>
{locale === "en-US"
? "Target Bucket:"
: "结算温度区间:"}
</span>
<strong className="market-target-bucket">
{settlementBucketLabel}
</strong>
</div>
<div className="market-edge-box">
<div className="market-edge-header">
<span>
{locale === "en-US" ? "Current edge" : "当前 edge"}
</span>
<strong
className={clsx(
"market-edge-val",
Number(marketScan?.edge_percent) > 0
? "positive"
: "negative",
)}
>
{marketEdge}
</strong>
</div>
<div className="market-edge-compare">
<div className="edge-stat">
<span className="edge-label">
{locale === "en-US" ? "Model prob" : "模型概率"}
</span>
<span className="edge-value">{modelProbability}</span>
</div>
<div className="edge-stat">
<span className="edge-label">
{locale === "en-US" ? "Market mid" : "市场中位"}
</span>
<span className="edge-value">{marketMidpoint}</span>
</div>
</div>
</div>
</div>
<div className="market-layer-book">
<div className="market-sub-title">
💸 {locale === "en-US" ? "Execution" : "盘口执行"}
</div>
<div className="market-book-row">
<span className="book-label">
{locale === "en-US" ? "Yes bid / ask" : "Yes 买 / 卖"}
</span>
<div className="book-quote">
<strong>{marketYesBuy}</strong>
<span> / {marketYesSell}</span>
</div>
</div>
<div className="market-book-row">
<span className="book-label">
{locale === "en-US" ? "No bid / ask" : "No 买 / 卖"}
</span>
<div className="book-quote">
<strong>{marketNoBuy}</strong>
<span> / {marketNoSell}</span>
</div>
</div>
<div className="market-book-row">
<span className="book-label">
{locale === "en-US" ? "Spread" : "价差"}
</span>
<div className="book-quote">
<strong>{marketSpread}</strong>
</div>
</div>
</div>
{/* Layer 3: Context */}
<div className="market-layer-context">
<div className="market-sub-title">
👀 {locale === "en-US" ? "Market Radar" : "情绪雷达"}
</div>
<div className="market-context-row">
<span>
{locale === "en-US"
? "Top Volume Bucket:"
: "市场当前押注最热:"}
</span>
<strong>
{hottestBucketLabel}{" "}
{hottestBucketProb !== "--"
? `(${hottestBucketProb})`
: ""}
</strong>
</div>
</div>
</div>
<div className="future-v2-market-signal mt-3">
{locale === "en-US" ? "Signal" : "信号"}:{" "}
<strong>{marketSignal}</strong>
</div>
<div className="future-v2-pace-summary" style={{ marginTop: "10px" }}>
{marketExecutionSummary}
</div>
</section>
<section className="future-v2-card">
<h4 className="future-v2-card-title">
{locale === "en-US"
? "Trade Snapshot"
: "交易快照"}
</h4>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Day high so far" : "日内已见高点"}</span>
<strong>
{topObservedTemp ?? "--"}
{detail.temp_symbol}
</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Current obs" : "当前观测"}</span>
<strong>{currentTempText}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Market signal" : "市场信号"}</span>
<strong>{marketSignal}</strong>
</div>
<div className="future-v2-mini-item">
<span>{locale === "en-US" ? "Current edge" : "当前 edge"}</span>
<strong>{marketEdge}</strong>
</div>
</div>
</section>
</aside> </aside>
<main className="future-v2-right"> <main className="future-v2-right">
@@ -1947,18 +1453,11 @@ export function FutureForecastModal() {
{Math.round(view.front.precipMax)}% {Math.round(view.front.precipMax)}%
</span> </span>
</div> </div>
{todayTradeSummaryLines.length > 0 || view.front.summary ? ( {todayTradeSummaryLines.length > 0 ? (
<div className="future-trend-summary"> <div className="future-trend-summary">
{[ {todayTradeSummaryLines.map((line, index) => (
...todayTradeSummaryLines, <div key={`${index}-${line}`}>{line}</div>
...(Array.isArray(view.front.summaryLines) ))}
? view.front.summaryLines
: view.front.summary
? [view.front.summary]
: []),
].map((line, index) => (
<div key={`${index}-${line}`}>{line}</div>
))}
</div> </div>
) : null} ) : null}
</div> </div>
+5
View File
@@ -407,6 +407,11 @@ export interface CityDetail {
dynamic_commentary?: { dynamic_commentary?: {
summary?: string | null; summary?: string | null;
notes?: 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?: { taf?: {
source?: string | null; source?: string | null;
+33 -12
View File
@@ -44,6 +44,32 @@ function containsCjk(text: string) {
return /[\u3400-\u9fff]/.test(text); 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) { function isTurkishMgmCity(detail: CityDetail) {
const city = String(detail.name || detail.display_name || "") const city = String(detail.name || detail.display_name || "")
.trim() .trim()
@@ -1327,21 +1353,16 @@ export function computeFrontTrendSignal(
tone?: string; tone?: string;
value: string; value: string;
}> = []; }> = [];
const rawBackendSummary = const localizedCommentary =
dateStr === detail.local_date dateStr === detail.local_date
? String(detail.dynamic_commentary?.summary || "").trim() ? getLocalizedDynamicCommentary(detail, locale)
: ""; : { headline: "", bullets: [], source: "" };
const backendSummary = const backendSummary =
rawBackendSummary && localizedCommentary.headline &&
(!isEnglish(locale) || !containsCjk(rawBackendSummary)) (!isEnglish(locale) || !containsCjk(localizedCommentary.headline))
? rawBackendSummary ? localizedCommentary.headline
: ""; : "";
const rawBackendNotes = Array.isArray(detail.dynamic_commentary?.notes) const backendNotes = localizedCommentary.bullets.filter(
? detail.dynamic_commentary?.notes
?.map((item) => String(item || "").trim())
.filter(Boolean) || []
: [];
const backendNotes = rawBackendNotes.filter(
(note) => !isEnglish(locale) || !containsCjk(note), (note) => !isEnglish(locale) || !containsCjk(note),
); );
const slice = getFutureSlice(detail, dateStr); const slice = getFutureSlice(detail, dateStr);
+231 -1
View File
@@ -1,11 +1,15 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
import os
import re import re
import time as _time import time as _time
import threading import threading
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
import httpx
from fastapi import HTTPException from fastapi import HTTPException
from loguru import logger from loguru import logger
@@ -38,6 +42,11 @@ _ANALYSIS_CACHE_STATS: Dict[str, Any] = {
"last_cache_miss_at": None, "last_cache_miss_at": None,
"last_city": 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: 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 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( def _interpolate_hourly_value(
times: list, times: list,
values: 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.""" """Fetch, analyse, and return structured weather data for one city."""
# Check cache # Check cache
ttl = CACHE_TTL_ANKARA if city.lower() in TURKISH_MGM_CITIES else CACHE_TTL 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: if not force_refresh:
cached = _cache.get(city) cached = _cache.get(city)
if cached and _time.time() - cached["t"] < ttl: 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) _record_analysis_cache_event(city=city, hit=True, force_refresh=False)
return cached["d"] return cached["d"]
_record_analysis_cache_event(city=city, hit=False, force_refresh=force_refresh) _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(), "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} _cache[city] = {"t": _time.time(), "d": result}
return result return result
+2 -2
View File
@@ -1018,7 +1018,7 @@ async def payment_reconcile_latest(request: Request):
@router.get("/api/city/{name}/summary") @router.get("/api/city/{name}/summary")
async def city_summary(request: Request, name: str, force_refresh: bool = False): async def city_summary(request: Request, name: str, force_refresh: bool = False):
city = _normalize_city_or_404(name) 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) return await run_in_threadpool(_build_city_summary_payload, data)
@@ -1032,7 +1032,7 @@ async def city_detail_aggregate(
): ):
_assert_entitlement(request) _assert_entitlement(request)
city = _normalize_city_or_404(name) 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( return await run_in_threadpool(
_build_city_detail_payload, _build_city_detail_payload,
data, data,