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
|
# 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user