diff --git a/src/analysis/ai_analyzer.py b/src/analysis/ai_analyzer.py index a29a1897..e42e4113 100644 --- a/src/analysis/ai_analyzer.py +++ b/src/analysis/ai_analyzer.py @@ -24,46 +24,48 @@ def get_ai_analysis(weather_insights: str, city_name: str, temp_symbol: str) -> headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} prompt = f""" -你是一个专业的天气衍生品(如 Polymarket)交易员。你的任务是分析当前天气特征,判断今日实测最高温是否能达到或超过预报中的【最高值】。 +你是一个专业的天气衍生品交易员。你的任务是分析当前气象实况,判断今日实测最高温的结算落点。 +结算基准为合约指定源(通常为 METAR 口径整数四舍五入),以下用"结算值"代替具体源名称。 请综合以下提供的【{city_name}】气象特征进行深度推理。 【气象特征与事实】 {weather_insights} -【分析优先级】(按此顺序逐项检查,前置项可推翻后置项的结论) +【分析框架】(按此顺序逐项检查,前置项可约束后置项的结论) -P0 **预报崩盘检测**(最最高优先级): - - 如果数据中出现"🚨 预报崩盘"标记,说明所有模型集体严重高估。 - - 此时你必须:① 直接宣布预报失准/崩盘;② 以实测最高温为结算锚点;③ 分析可能导致崩盘的原因(冷平流、降水、云量压制等);④ 禁止再讨论"冲击"预报值。 - - 如果峰值窗口已过且实测远低于预报,结算已基本确定,应直接给出结算值。 +P0 **预报失准检测**: + - 若数据含"🚨 预报崩盘"或"⚠️ 预报差距"标记,判定预报失准。 + - 失准等级:轻(偏差2-3°) / 中(3-5°) / 重(>5°)。 + - 但"失准"≠"已定局":还需检查近2报斜率是否≤0 且 风向/云量不支持二次抬升,才能判定结算锁定。 + - 若斜率仍>0或有暖平流迹象,应注明"预报偏高但仍有上行空间"。 P1 **实况节奏**: - 近2-4条METAR的温度走势:连涨/持平/回落? - - 触发阈值:连续2报创新高 → 升温未止;连续2报未创新高且已过峰值窗 → 偏死盘。 - - 低辐射时段(<100W/m²)仍在升温 → 暖平流驱动,预报往往低估。 + - 连续2报创新高 → 升温未止;连续2报未创新高且斜率≤0 → 偏死盘。 + - 升温出现在低辐射时段 → 可能有多因子叠加(平流/混合层/热岛),不可单因子归因。 -P2 **阻碍因子**: - - 触发阈值:湿度>80% 且 云量BKN/OVC持续2报 → 判定压温有效。 +P2 **阻碍因子**(需结合城市特性判断): - 降水已出现(非trace) → 强压温。 - - 若阻碍条件未满足,不可凭"多云"单因子就判断"升温受限"。 + - 高湿度+厚云层持续2报以上 → 压温可能有效,但阈值因城市(海洋型 vs 大陆型)而异,不可套用固定数值。 + - 若仅单因子(如仅多云),不足以断定"升温受限"。 -P3 **数学概率**: - - 参考我提供的结算概率分布,但不可用概率去压过 P0/P1 实况。 - - 概率是辅助判据,实测趋势是主判据。 +P3 **概率与一致性校验**: + - 参考结算概率分布,与 P1 实况做一致性检查。 + - 若概率分布与实况趋势矛盾,以实况为准并说明偏离原因。 + - 概率可辅助判断边界进位风险(如 X.5 线附近)。 P4 **预报背景**(最低优先级): - - DEB融合值和预报可用于判断上沿空间和回落节奏。 - - 但当实测已远低于预报时,预报值已失去参考意义,不可继续引用。 - - 结算边界:温度处于 X.5 进位线附近时需特别预警。 + - 可参考 DEB/预报做上沿空间评估。 + - 当实测已显著偏离预报时,禁止继续引用预报值作为目标。 【输出要求】 -1. **禁止废话**,整体控制在 300 字以内。 +1. 正常场景控制在 300 字左右;异常场景(预报失准/极端走势)可扩展到 450 字。 2. 严格按照以下 HTML 格式输出: 🤖 Groq AI 决策 -- 🎲 盘口: [若预报崩盘,直接给出"预报集体失准,WU结算锁定X°C"。否则:明确指出最热时段以及博弈焦点。区分"已确认WU底线 X°C"与"仍有概率冲击 Y°C"。禁止在升温未止时使用"锁定"等封顶措辞。若符合死盘条件,请直接给出死盘结论并说明理由。] -- 💡 逻辑: [用 3-5 句话深度分析。如果预报崩盘,重点分析崩盘原因和实测偏差。如果正常,分析实测与预报的博弈和动力条件。需包含具体数值。] +- 🎲 盘口: [给出结算判断。用"已确认底线 X{temp_symbol}"表示下限确定;用"上沿待确认,关注 Y{temp_symbol}"表示仍有变数。若预报严重失准,注明失准等级和原因。禁止在升温未止时用"锁定"。] +- 💡 逻辑: [3-5 句深度分析。含具体数值。预报失准时重点分析偏差成因。正常时分析实测与预报的动态博弈。] - 🎯 置信度: [1-10]/10 """ @@ -86,7 +88,7 @@ P4 **预报背景**(最低优先级): {"role": "user", "content": prompt}, ], "temperature": 0.5, - "max_tokens": 250, + "max_tokens": 400, } response = requests.post( diff --git a/web/app.py b/web/app.py index a51433df..3fc6dc8f 100644 --- a/web/app.py +++ b/web/app.py @@ -502,18 +502,27 @@ def _analyze(city: str) -> Dict[str, Any]: ) ai_parts.append(f"模型分歧: {mm_str}") - # --- Forecast bust detection for AI --- + # --- Forecast miss severity for AI --- if forecast_miss_deg > 2.0 and peak_status in ("past", "in_window"): + if forecast_miss_deg > 5.0: + severity = "重" + elif forecast_miss_deg > 3.0: + severity = "中" + else: + severity = "轻" + min_forecast = min( (v for v in current_forecasts.values() if v is not None), default=None ) + slope_info = trend_info["direction"] ai_parts.append( - f"🚨 预报崩盘: 所有模型集体高估!最低预报 {min_forecast}{sym} vs 实测最高 {max_so_far}{sym}," - f"偏差 {forecast_miss_deg}°。已进入/过了峰值窗口,温度严重不达预期。" + f"🚨 预报崩盘 [{severity}级失准]: 最低预报 {min_forecast}{sym} vs 实测最高 {max_so_far}{sym}," + f"偏差 {forecast_miss_deg}°。当前趋势: {slope_info}。" ) elif forecast_miss_deg > 4.0 and peak_status == "before": ai_parts.append( - f"⚠️ 预报差距: 距峰值窗口尚有时间,但实测已落后预报 {forecast_miss_deg}°。" + f"⚠️ 预报差距 [轻级]: 距峰值窗口尚有时间,但实测已落后预报 {forecast_miss_deg}°。" + f"当前趋势: {trend_info['direction']}。" ) ai_context = "\n".join(ai_parts) diff --git a/web/static/app.js b/web/static/app.js index 1210a788..2914e1c9 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -255,12 +255,6 @@ function renderPanel(data) { // Trigger reflow for animation requestAnimationFrame(() => panel.classList.add("visible")); - // Start weather animation - if (typeof WeatherAnim !== "undefined") { - // Small delay for canvas to be in DOM - setTimeout(() => WeatherAnim.start(data), 100); - } - // Header document.getElementById("panelCityName").textContent = `${data.risk?.emoji || "🏙️"} ${data.display_name}`; @@ -716,8 +710,6 @@ function closePanel() { document .querySelectorAll(".city-item") .forEach((el) => el.classList.remove("active")); - // Stop weather animation - if (typeof WeatherAnim !== "undefined") WeatherAnim.stop(); } // ────────────────────────────────────────────────────────── diff --git a/web/static/index.html b/web/static/index.html index d6d1ad89..ebe2679f 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -63,11 +63,6 @@
- -
- -
-
@@ -153,7 +148,6 @@ - diff --git a/web/static/style.css b/web/static/style.css index a5272ef6..479d5c31 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -495,81 +495,10 @@ body { letter-spacing: 0.3px; } -/* ── Weather Animation ── */ -.weather-anim-container { - position: relative; - width: 100%; - height: 200px; - overflow: hidden; - border-radius: 16px; - margin-bottom: -180px; /* Overlap with hero */ - z-index: 0; -} - -.weather-anim-container canvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; -} - -/* Night sky gradient for the animation background */ -.weather-anim-container.active { - background: linear-gradient( - 180deg, - rgba(10, 14, 30, 0.9) 0%, - rgba(15, 20, 40, 0.6) 40%, - rgba(10, 14, 26, 0.3) 100% - ); -} -.weather-anim-container.clear { - background: linear-gradient( - 180deg, - rgba(30, 58, 95, 0.5) 0%, - rgba(15, 25, 50, 0.3) 60%, - transparent 100% - ); -} -.weather-anim-container.rain { - background: linear-gradient( - 180deg, - rgba(30, 40, 60, 0.7) 0%, - rgba(20, 30, 50, 0.4) 60%, - transparent 100% - ); -} -.weather-anim-container.snow { - background: linear-gradient( - 180deg, - rgba(50, 60, 80, 0.6) 0%, - rgba(30, 40, 60, 0.3) 60%, - transparent 100% - ); -} -.weather-anim-container.fog { - background: linear-gradient( - 180deg, - rgba(100, 110, 130, 0.4) 0%, - rgba(60, 70, 90, 0.2) 60%, - transparent 100% - ); -} -.weather-anim-container.storm { - background: linear-gradient( - 180deg, - rgba(20, 20, 35, 0.85) 0%, - rgba(15, 15, 30, 0.5) 60%, - transparent 100% - ); -} - /* ── Hero Section ── */ .hero-section { text-align: center; padding-top: 12px !important; - position: relative; - z-index: 1; } .hero-weather { diff --git a/web/static/weather-anim.js b/web/static/weather-anim.js deleted file mode 100644 index c6d1dc23..00000000 --- a/web/static/weather-anim.js +++ /dev/null @@ -1,285 +0,0 @@ -// ────────────────────────────────────────────────────────── -// Weather Animation Engine -// Canvas-based particle system for rain, snow, fog, etc. -// ────────────────────────────────────────────────────────── - -const WeatherAnim = (() => { - let canvas, ctx; - let animId = null; - let particles = []; - let currentType = null; - let lightningTimer = 0; - let lightningFlash = 0; - - const CONFIGS = { - rain: { - count: 120, - init: (w, h) => ({ - x: Math.random() * w * 1.2 - w * 0.1, - y: Math.random() * h - h, - len: 12 + Math.random() * 18, - speed: 8 + Math.random() * 6, - opacity: 0.2 + Math.random() * 0.4, - wind: 2 + Math.random() * 2, - }), - draw: (p, ctx) => { - ctx.beginPath(); - ctx.moveTo(p.x, p.y); - ctx.lineTo(p.x + p.wind * 0.5, p.y + p.len); - ctx.strokeStyle = `rgba(174, 214, 241, ${p.opacity})`; - ctx.lineWidth = 1.5; - ctx.stroke(); - }, - update: (p, w, h) => { - p.y += p.speed; - p.x += p.wind; - if (p.y > h) { - p.y = -p.len; - p.x = Math.random() * w * 1.2 - w * 0.1; - } - }, - }, - - heavyrain: { - count: 200, - init: (w, h) => ({ - x: Math.random() * w * 1.3 - w * 0.15, - y: Math.random() * h - h, - len: 18 + Math.random() * 24, - speed: 12 + Math.random() * 8, - opacity: 0.3 + Math.random() * 0.5, - wind: 4 + Math.random() * 3, - }), - draw: (p, ctx) => { - ctx.beginPath(); - ctx.moveTo(p.x, p.y); - ctx.lineTo(p.x + p.wind * 0.6, p.y + p.len); - ctx.strokeStyle = `rgba(160, 200, 230, ${p.opacity})`; - ctx.lineWidth = 2; - ctx.stroke(); - }, - update: (p, w, h) => { - p.y += p.speed; - p.x += p.wind; - if (p.y > h) { - p.y = -p.len; - p.x = Math.random() * w * 1.3 - w * 0.15; - } - }, - }, - - snow: { - count: 80, - init: (w, h) => ({ - x: Math.random() * w, - y: Math.random() * h - h, - r: 1.5 + Math.random() * 3, - speed: 0.8 + Math.random() * 1.5, - opacity: 0.4 + Math.random() * 0.4, - wobble: Math.random() * Math.PI * 2, - wobbleSpeed: 0.01 + Math.random() * 0.03, - wobbleAmp: 0.5 + Math.random() * 1.5, - }), - draw: (p, ctx) => { - ctx.beginPath(); - ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); - ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity})`; - ctx.fill(); - }, - update: (p, w, h) => { - p.y += p.speed; - p.wobble += p.wobbleSpeed; - p.x += Math.sin(p.wobble) * p.wobbleAmp; - if (p.y > h + p.r) { - p.y = -p.r * 2; - p.x = Math.random() * w; - } - }, - }, - - fog: { - count: 15, - init: (w, h) => ({ - x: Math.random() * w, - y: 20 + Math.random() * (h - 40), - w: 120 + Math.random() * 200, - h: 30 + Math.random() * 60, - speed: 0.15 + Math.random() * 0.3, - opacity: 0.03 + Math.random() * 0.08, - phase: Math.random() * Math.PI * 2, - }), - draw: (p, ctx) => { - const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.w / 2); - const osc = Math.sin(p.phase) * 0.02; - grad.addColorStop(0, `rgba(200, 210, 230, ${p.opacity + osc})`); - grad.addColorStop(1, "rgba(200, 210, 230, 0)"); - ctx.fillStyle = grad; - ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); - }, - update: (p, w) => { - p.x += p.speed; - p.phase += 0.01; - if (p.x - p.w / 2 > w) { - p.x = -p.w / 2; - } - }, - }, - - clear: { - count: 0, - init: () => ({}), - draw: () => {}, - update: () => {}, - }, - - cloudy: { - count: 8, - init: (w, h) => ({ - x: Math.random() * w, - y: 10 + Math.random() * 60, - w: 100 + Math.random() * 160, - h: 25 + Math.random() * 35, - speed: 0.1 + Math.random() * 0.15, - opacity: 0.04 + Math.random() * 0.06, - }), - draw: (p, ctx) => { - const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.w / 2); - grad.addColorStop(0, `rgba(150, 160, 180, ${p.opacity})`); - grad.addColorStop(1, "rgba(150, 160, 180, 0)"); - ctx.fillStyle = grad; - ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h); - }, - update: (p, w) => { - p.x += p.speed; - if (p.x - p.w / 2 > w) p.x = -p.w / 2; - }, - }, - }; - - function detectWeatherType(data) { - if (!data || !data.current) return "clear"; - const wx = (data.current.wx_desc || "").toUpperCase(); - const cloud = (data.current.cloud_desc || "").toLowerCase(); - - // Priority: precipitation > fog > clouds > clear - if (wx.includes("TS") || wx.includes("TSRA")) return "storm"; - if (wx.includes("+RA") || wx.includes("+SN")) return "heavyrain"; - if (wx.includes("RA") || wx.includes("DZ") || wx.includes("SHRA")) - return "rain"; - if (wx.includes("SN") || wx.includes("GR") || wx.includes("GS")) - return "snow"; - if (wx.includes("FG") || wx.includes("BR") || wx.includes("HZ")) - return "fog"; - - // Cloud-based - if (cloud.includes("阴天") || cloud.includes("多云")) return "cloudy"; - - return "clear"; - } - - function initCanvas() { - canvas = document.getElementById("weatherCanvas"); - if (!canvas) return false; - const container = canvas.parentElement; - canvas.width = container.offsetWidth * window.devicePixelRatio; - canvas.height = container.offsetHeight * window.devicePixelRatio; - canvas.style.width = container.offsetWidth + "px"; - canvas.style.height = container.offsetHeight + "px"; - ctx = canvas.getContext("2d"); - ctx.scale(window.devicePixelRatio, window.devicePixelRatio); - return true; - } - - function createParticles(type) { - const config = CONFIGS[type] || CONFIGS.clear; - const container = canvas.parentElement; - const w = container.offsetWidth; - const h = container.offsetHeight; - particles = []; - for (let i = 0; i < config.count; i++) { - particles.push(config.init(w, h)); - } - } - - function animate() { - if (!ctx || !canvas) return; - const container = canvas.parentElement; - const w = container.offsetWidth; - const h = container.offsetHeight; - const config = CONFIGS[currentType] || CONFIGS.clear; - - ctx.clearRect(0, 0, w, h); - - // Lightning effect for storms - if (currentType === "storm") { - lightningTimer++; - if (lightningTimer > 120 + Math.random() * 200) { - lightningFlash = 6; - lightningTimer = 0; - } - if (lightningFlash > 0) { - const alpha = (lightningFlash / 6) * 0.25; - ctx.fillStyle = `rgba(200, 220, 255, ${alpha})`; - ctx.fillRect(0, 0, w, h); - lightningFlash--; - } - - // Storm uses heavyrain particles - const stormConfig = CONFIGS.heavyrain; - particles.forEach((p) => { - stormConfig.draw(p, ctx); - stormConfig.update(p, w, h); - }); - } else { - particles.forEach((p) => { - config.draw(p, ctx); - config.update(p, w, h); - }); - } - - animId = requestAnimationFrame(animate); - } - - function start(data) { - stop(); - if (!initCanvas()) return; - - const type = detectWeatherType(data); - currentType = type; - - // Set container CSS class for background gradient - const container = document.getElementById("weatherAnimContainer"); - container.className = "weather-anim-container active " + type; - - // Storm uses heavyrain particles config - if (type === "storm") { - const stormW = container.offsetWidth; - const stormH = container.offsetHeight; - particles = []; - for (let i = 0; i < CONFIGS.heavyrain.count; i++) { - particles.push(CONFIGS.heavyrain.init(stormW, stormH)); - } - lightningTimer = 0; - lightningFlash = 0; - } else { - createParticles(type); - } - - animate(); - } - - function stop() { - if (animId) { - cancelAnimationFrame(animId); - animId = null; - } - particles = []; - currentType = null; - const container = document.getElementById("weatherAnimContainer"); - if (container) { - container.className = "weather-anim-container"; - } - } - - return { start, stop, detectWeatherType }; -})();