Add Groq commentary config and simplify forecast modal
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -1642,220 +1362,6 @@ export function FutureForecastModal() {
|
||||
</section>
|
||||
) : 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>
|
||||
|
||||
<main className="future-v2-right">
|
||||
@@ -1947,18 +1453,11 @@ export function FutureForecastModal() {
|
||||
{Math.round(view.front.precipMax)}%
|
||||
</span>
|
||||
</div>
|
||||
{todayTradeSummaryLines.length > 0 || view.front.summary ? (
|
||||
{todayTradeSummaryLines.length > 0 ? (
|
||||
<div className="future-trend-summary">
|
||||
{[
|
||||
...todayTradeSummaryLines,
|
||||
...(Array.isArray(view.front.summaryLines)
|
||||
? view.front.summaryLines
|
||||
: view.front.summary
|
||||
? [view.front.summary]
|
||||
: []),
|
||||
].map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
{todayTradeSummaryLines.map((line, index) => (
|
||||
<div key={`${index}-${line}`}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+231
-1
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user