diff --git a/.env.example b/.env.example index 902bc2db..1bdb56aa 100644 --- a/.env.example +++ b/.env.example @@ -63,7 +63,6 @@ POLYWEATHER_METAR_TIMEOUT_SEC=4 POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5 METAR_CACHE_TTL_SEC=600 JMA_AMEDAS_CACHE_TTL_SEC=120 -METEOBLUE_CACHE_TTL_SEC=7200 # Recommended on VPS: do not train there; pull the SQLite DB to a local machine. # Optional: set this to a writable runtime path if you manually deploy a diff --git a/frontend/.env.local b/frontend/.env.local index c049a0d8..2e7926cc 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -1 +1,43 @@ +# PolyWeather 前端最小配置(本地 / Vercel) +# 只部署天气看板时,先填下面 4 项即可。 + +# 必填:后端 FastAPI 基础地址 +# 默认供 Next.js API Route 在服务端代理后端使用。 POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 + +# 可选:浏览器直连后端 FastAPI 基础地址。 +# 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等 +# 长耗时请求绕过 Vercel Functions / Fluid Compute。 +# 例如:https://api.example.com +NEXT_PUBLIC_POLYWEATHER_API_BASE_URL= + +# 必填:Supabase 前端公钥(鉴权开启时必须) +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# 常用:前端鉴权开关 +# true: 启用 Supabase 登录 +# false: 关闭登录能力,访客模式 +POLYWEATHER_AUTH_ENABLED=false + +# 常用:是否强制登录 +# true: middleware 强制登录后才能访问主页面 +# false: 登录可选,访客可浏览 +POLYWEATHER_AUTH_REQUIRED=false + +# 可选:分享式看板访问令牌 +# 设置后,可通过 /?access_token= 打开受保护看板 +POLYWEATHER_DASHBOARD_ACCESS_TOKEN= + +# 可选:前端 API Route 转发到后端时附带的共享令牌 +# 仅当后端启用了 entitlement / 订阅校验时需要 +POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= + +# 可选:钱包支付 / Telegram 入口 +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= +NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com +NEXT_PUBLIC_PAYMENT_ALLOWED_HOSTS=polyweather-pro.vercel.app +POLYWEATHER_OPS_ADMIN_EMAILS=yhrsc30@gmail.com +NEXT_PUBLIC_TELEGRAM_GROUP_URL=https://t.me/your_group +NEXT_PUBLIC_TELEGRAM_BOT_URL=https://t.me/WeatherQuant_bot +NEXT_PUBLIC_TELEGRAM_LOGIN_BOT_USERNAME=WeatherQuant_bot diff --git a/frontend/README.md b/frontend/README.md index 9e8344e2..da2462a5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -21,8 +21,8 @@ PolyWeather Pro 的生产前端工程。 ## 当前前端能力 -- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心 -- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF、结算来源和历史对账 +- 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心 +- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF 和结算来源 - 今日日内分析支持: - `锚点状态` - `当前节奏` @@ -31,9 +31,6 @@ PolyWeather Pro 的生产前端工程。 - `专业气象结论条` - `气象证据链 / 失效条件 / 确认条件` - 非香港机场城市的 `TAF` 时段提示与走势图联动 -- 历史对账支持: - - `DEB / 最佳单模型 / 实测最高温` 对比 - - 峰值前 12 小时 `DEB` 参考(近似) - `/ops` 已支持桌面表格 + 手机端卡片化视图 - 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住 - 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果 @@ -117,7 +114,6 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false - `GET /api/city/[name]` - `GET /api/city/[name]/summary` - `GET /api/city/[name]/detail` -- `GET /api/history/[name]` 鉴权: diff --git a/frontend/app/api/history/[name]/route.ts b/frontend/app/api/history/[name]/route.ts deleted file mode 100644 index 9fd48611..00000000 --- a/frontend/app/api/history/[name]/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { proxyBackendJsonGet } from "@/lib/api-proxy"; - -const API_BASE = process.env.POLYWEATHER_API_BASE_URL; - -export async function GET( - req: NextRequest, - context: { params: Promise<{ name: string }> }, -) { - if (!API_BASE) { - const response = NextResponse.json( - { error: "POLYWEATHER_API_BASE_URL is not configured" }, - { status: 500 }, - ); - return response; - } - - const { name } = await context.params; - const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`; - - return proxyBackendJsonGet(req, { - cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300", - publicMessage: "Failed to fetch history", - revalidateSeconds: 60, - url, - }); -} diff --git a/frontend/components/dashboard/HistoryChart.tsx b/frontend/components/dashboard/HistoryChart.tsx deleted file mode 100644 index 81490acf..00000000 --- a/frontend/components/dashboard/HistoryChart.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client"; - -import type { ChartConfiguration } from "chart.js"; -import { useMemo } from "react"; -import { useChart } from "@/hooks/useChart"; -import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore"; -import { useI18n } from "@/hooks/useI18n"; -import { getHistorySummary } from "@/lib/dashboard-utils"; - -export function HistoryChart() { - const store = useDashboardStore(); - const { locale } = useI18n(); - const { data } = useHistoryData(); - const isNoaaSettlement = - store.selectedDetail?.current?.settlement_source === "noaa" || - store.selectedDetail?.current?.settlement_source_label === "NOAA"; - const noaaStationCode = String( - store.selectedDetail?.current?.station_code || - store.selectedDetail?.risk?.icao || - "NOAA", - ) - .trim() - .toUpperCase(); - const summary = useMemo( - () => getHistorySummary(data, store.selectedDetail?.local_date), - [data, store.selectedDetail?.local_date], - ); - const hasMgm = - store.selectedCity === "ankara" && - summary.mgmSeriesComplete && - summary.mgms.some((value) => value != null); - const hasBestBaseline = - Boolean(summary.bestModelName) && - summary.bestModelName !== "MGM" && - summary.bestModelSeries.some((value) => value != null); - - const canvasRef = useChart(() => { - const datasets: NonNullable< - ChartConfiguration<"line">["data"] - >["datasets"] = [ - { - backgroundColor: "rgba(248, 113, 113, 0.1)", - borderColor: "#f87171", - borderWidth: 2, - data: summary.actuals, - label: isNoaaSettlement - ? locale === "en-US" - ? `NOAA Settled High (${noaaStationCode})` - : `NOAA 结算最高温 (${noaaStationCode})` - : locale === "en-US" - ? "Observed High" - : "实测最高温", - pointBackgroundColor: "#f87171", - pointBorderColor: "#fff", - pointHoverRadius: 7, - pointRadius: 5, - tension: 0.1, - }, - { - backgroundColor: "transparent", - borderColor: "#34d399", - borderDash: [5, 4], - borderWidth: 2, - data: summary.debs, - label: locale === "en-US" ? "DEB Fusion" : "DEB 融合", - pointHoverRadius: 6, - pointRadius: 4, - tension: 0.1, - }, - ]; - - if (hasMgm) { - datasets.push({ - backgroundColor: "transparent", - borderColor: "#fb923c", - borderWidth: 2, - data: summary.mgms, - label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报", - pointHoverRadius: 6, - pointRadius: 4, - tension: 0.1, - }); - } - - if (hasBestBaseline) { - datasets.push({ - backgroundColor: "transparent", - borderColor: "#60a5fa", - borderDash: [4, 3], - borderWidth: 2, - data: summary.bestModelSeries, - label: - locale === "en-US" - ? `Best Baseline (${summary.bestModelName})` - : `最佳单模型 (${summary.bestModelName})`, - pointHoverRadius: 6, - pointRadius: 4, - tension: 0.1, - }); - } - - return { - data: { - datasets, - labels: summary.dates, - }, - options: { - interaction: { intersect: false, mode: "index" }, - maintainAspectRatio: false, - plugins: { - legend: { - labels: { - boxHeight: 12, - boxWidth: 34, - color: "#94a3b8", - font: { family: "Inter", size: 14 }, - padding: 18, - }, - }, - tooltip: { - backgroundColor: "rgba(15, 23, 42, 0.9)", - borderColor: "rgba(255, 255, 255, 0.1)", - borderWidth: 1, - bodyFont: { family: "Inter", size: 13 }, - titleFont: { family: "Inter", size: 13, weight: 600 }, - callbacks: { - label: (ctx) => - `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${store.selectedDetail?.temp_symbol || "°C"}`, - }, - }, - }, - responsive: true, - scales: { - x: { - grid: { color: "rgba(255,255,255,0.04)" }, - ticks: { - color: "#64748b", - font: { family: "Inter", size: 12 }, - padding: 8, - }, - }, - y: { - grid: { color: "rgba(255,255,255,0.04)" }, - ticks: { - color: "#64748b", - font: { family: "Inter", size: 12 }, - padding: 8, - }, - }, - }, - }, - type: "line", - } satisfies ChartConfiguration<"line">; - }, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]); - - if (!summary.recentData.length) return null; - - return ( -
- -
- ); -} diff --git a/frontend/components/dashboard/MapCanvas.tsx b/frontend/components/dashboard/MapCanvas.tsx index 3f626264..cdf84dc2 100644 --- a/frontend/components/dashboard/MapCanvas.tsx +++ b/frontend/components/dashboard/MapCanvas.tsx @@ -31,9 +31,7 @@ export function MapCanvas({ }, selectedCity: store.selectedCity, selectedDetail: store.selectedDetail, - suspendMotion: - Boolean(store.futureModalDate) || - store.historyState.isOpen, + suspendMotion: Boolean(store.futureModalDate), isLoadingDetail: store.loadingState.cityDetail, }); diff --git a/frontend/components/dashboard/ProbabilityDistribution.tsx b/frontend/components/dashboard/ProbabilityDistribution.tsx index 0c445648..df964cea 100644 --- a/frontend/components/dashboard/ProbabilityDistribution.tsx +++ b/frontend/components/dashboard/ProbabilityDistribution.tsx @@ -343,7 +343,6 @@ function getRoundedModelVoteDistribution( Object.entries(view.models || {}).forEach(([name, rawValue]) => { const normalized = normalizeModelNameForVote(name); - if (normalized === "lgbm" || normalized.includes("meteoblue")) return; const value = Number(rawValue); if (!Number.isFinite(value)) return; const family = getModelVoteFamily(name); diff --git a/frontend/content/docs/docs.config.ts b/frontend/content/docs/docs.config.ts index 0cf3c9d4..4462bed6 100644 --- a/frontend/content/docs/docs.config.ts +++ b/frontend/content/docs/docs.config.ts @@ -12,12 +12,7 @@ export const DOCS_GROUPS: DocsNavGroup[] = [ { id: "settlement", title: { "zh-CN": "结算与数据", "en-US": "Settlement & Data" }, - }, - { - id: "history", - title: { "zh-CN": "历史对账", "en-US": "History & Reconciliation" }, - }, -]; + },]; export function getDocsGroupTitle(groupId: DocsNavGroup["id"], locale: DocsLocale) { return DOCS_GROUPS.find((group) => group.id === groupId)?.title[locale] || groupId; diff --git a/frontend/content/docs/docs.ts b/frontend/content/docs/docs.ts index cea7adad..725c4cf5 100644 --- a/frontend/content/docs/docs.ts +++ b/frontend/content/docs/docs.ts @@ -22,7 +22,7 @@ export interface DocsPageContent { export interface DocsPageMeta { slug: string; - group: "getting-started" | "analysis" | "settlement" | "history"; + group: "getting-started" | "analysis" | "settlement"; } export interface DocsPage extends DocsPageMeta { @@ -55,7 +55,7 @@ export const DOCS_PAGES: DocsPage[] = [ id: "core-modules", title: "你会在页面上看到什么", blocks: [ - { type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取 AI 机场报文解读、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。", "历史对账:查看已结算样本、DEB MAE、单模型表现和新增模型参考。"] }, + { type: "bullets", items: ["锚点状态:先确认当前机场主站实测、日内已见高点和结算时钟。", "当前节奏:把“此刻应到温度”和“机场实测”放在一张卡里,判断今天跑得快还是慢。", "专业气象结论条:先给今日主判断、置信度、基准/上修/下修路径和下一观测点。", "城市决策卡:从地图进入城市简报,读取 AI 机场报文解读、最高温中枢、市场温度桶和模型-市场差。", "校准模型概率 / 模型区间与分歧:概率层看当前生产概率引擎输出;EMOS / LGBM 只有在评估通过或 shadow 对照时进入解释层,模型区间用于解释分歧。", "气象证据链 / 失效条件 / 确认条件:解释为什么这么判断,以及什么情况会让判断降级。"] }, ], }, { @@ -83,7 +83,7 @@ export const DOCS_PAGES: DocsPage[] = [ id: "core-modules", title: "What you see on the site", blocks: [ - { type: "bullets", items: ["Anchor status: current airport-primary observation, day-high-so-far, and the settlement clock.", "Current pace: compares where the airport should be by now versus the actual observation.", "Professional meteorology read: headline, confidence, base/upside/downside path, and next observation point.", "City decision cards: map-launched city briefs with the AI airport read, expected-high center, market bucket, and model-market difference.", "Calibrated model probability / model spread: probability comes from the calibrated engine; spread explains model disagreement.", "Evidence chain / failure modes / confirmation: why the read is valid and what would downgrade it.", "History reconciliation: settled-sample MAE, single-model performance, and the new model reference stack."] }, + { type: "bullets", items: ["Anchor status: current airport-primary observation, day-high-so-far, and the settlement clock.", "Current pace: compares where the airport should be by now versus the actual observation.", "Professional meteorology read: headline, confidence, base/upside/downside path, and next observation point.", "City decision cards: map-launched city briefs with the AI airport read, expected-high center, market bucket, and model-market difference.", "Calibrated model probability / model spread: probability comes from the calibrated engine; spread explains model disagreement.", "Evidence chain / failure modes / confirmation: why the read is valid and what would downgrade it."] }, ], }, { @@ -465,82 +465,6 @@ export const DOCS_PAGES: DocsPage[] = [ }, }, }, - { - slug: "history-reconciliation", - group: "history", - content: { - "zh-CN": { - title: "历史对账", - description: "历史对账用于看已结算样本,不用于把当天未结算的行情硬算进胜率。", - sections: [ - { - id: "settled-only", - title: "为什么只看已结算样本", - blocks: [ - { type: "paragraph", text: "网页上的历史对账只统计已结算样本。当天还在交易中的市场,不会被提前算进 DEB 命中率或 MAE。这样做的目的,是避免用还没兑现的结果污染历史准确率。" }, - ], - }, - { - id: "rolling-window", - title: "近 15 天滚动视图", - blocks: [ - { type: "paragraph", text: "网页默认展示近 15 天滚动视图,方便比较最近这轮模型状态,而不是用过长的旧样本稀释当前表现。" }, - ], - }, - { - id: "peak-minus-12h", - title: "峰值前 12 小时 DEB 参考", - blocks: [ - { type: "paragraph", text: "这项指标用来回答一个更具体的问题:在真正出现高温之前 12 小时,DEB 当时大概有多准。它不是额外结算规则,而是一个用来观察模型是否过慢修正的参考视角。" }, - { type: "callout", tone: "info", title: "近似值说明", text: "当前峰值时间是根据历史快照链路反推的近似时间,不是逐分钟官方复盘。页面会明确标记为“参考 / 近似”。" }, - ], - }, - { - id: "model-reference", - title: "新增模型参考", - blocks: [ - { type: "paragraph", text: "历史对账会保留 DEB、最佳单模型和实测最高温对比,同时加入当前模型栈的参考信息。新增模型用于解释当时模型家族分歧,不会 retroactively 改写已经结算的历史真值。" }, - ], - }, - ], - }, - "en-US": { - title: "History Reconciliation", - description: "History reconciliation is for settled samples only. It is not meant to leak same-day unsettled outcomes into historical hit-rate or MAE.", - sections: [ - { - id: "settled-only", - title: "Why only settled samples count", - blocks: [ - { type: "paragraph", text: "The history panel only counts settled samples. Markets still trading on the same day are excluded from DEB hit-rate and MAE so unfinished outcomes do not contaminate the historical record." }, - ], - }, - { - id: "rolling-window", - title: "Rolling 15-day view", - blocks: [ - { type: "paragraph", text: "The web dashboard defaults to a rolling 15-day view so the panel reflects current model behavior rather than being overly diluted by older regimes." }, - ], - }, - { - id: "peak-minus-12h", - title: "DEB at peak minus 12 hours", - blocks: [ - { type: "paragraph", text: "This field answers a more specific question: how good was DEB roughly 12 hours before the eventual high actually printed? It is not a settlement rule, but a way to judge whether the model corrected too slowly." }, - { type: "callout", tone: "info", title: "Approximation note", text: "The current peak time is inferred from the snapshot chain and should be treated as an approximate reference rather than a minute-perfect official replay." }, - ], - }, - { - id: "model-reference", - title: "New model reference", - blocks: [ - { type: "paragraph", text: "History reconciliation keeps the DEB, best single model, and observed high comparison, while adding the current model-stack reference. New model lines explain family spread at the time; they do not rewrite already settled truth records." }, - ], - }, - ], - }, - }, - }, { slug: "extension", group: "getting-started", @@ -557,7 +481,7 @@ export const DOCS_PAGES: DocsPage[] = [ type: "link", href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb", label: "打开 Chrome Web Store", - caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析与历史对账。", + caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析。", }, ], }, @@ -597,7 +521,7 @@ export const DOCS_PAGES: DocsPage[] = [ blocks: [ { type: "paragraph", - text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断、历史对账和完整交易语境仍以主站为准。", + text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断和完整交易语境仍以主站为准。", }, { type: "callout", @@ -631,7 +555,7 @@ export const DOCS_PAGES: DocsPage[] = [ type: "link", href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb", label: "Open Chrome Web Store", - caption: "Once installed, the side panel can route users back into the main intraday analysis and history views.", + caption: "Once installed, the side panel can route users back into the main intraday analysis.", }, ], }, diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index a35ebb03..08c042b2 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -28,10 +28,6 @@ import { CitySummary, DashboardState, ForecastModalMode, - HistoryPoint, - HistoryPayload, - HistoryPayloadMeta, - HistoryState, LoadingState, ProAccessState, } from "@/lib/dashboard-types"; @@ -39,7 +35,6 @@ import { interface DashboardStoreValue extends DashboardState { clearCityFocus: () => void; closeFutureModal: () => void; - closeHistory: () => void; closePanel: () => void; ensureCityDetail: ( cityName: string, @@ -61,7 +56,6 @@ interface DashboardStoreValue extends DashboardState { loadCities: () => Promise; preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void; openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise; - openHistory: () => Promise; openTodayModal: (forceRefresh?: boolean) => Promise; registerMapStopMotion: (stopMotion: () => void) => void; refreshAll: () => Promise; @@ -84,10 +78,6 @@ type DashboardModalContextValue = Pick< | "selectedForecastDate" | "setForecastDate" >; -type DashboardHistoryContextValue = Pick< - DashboardStoreValue, - "closeHistory" | "historyState" | "openHistory" ->; type DashboardProAccessContextValue = Pick< DashboardStoreValue, "proAccess" | "refreshProAccess" @@ -100,8 +90,6 @@ const DashboardActionsContext = createContext | null>(null); const DashboardModalContext = createContext(null); -const DashboardHistoryContext = - createContext(null); const DashboardProAccessContext = createContext(null); const DashboardSelectionContext = createContext( getInitialLoadingState, ); - const [historyState, setHistoryState] = useState( - getInitialHistoryState, - ); const [proAccess, setProAccess] = useState( getInitialProAccessState, ); @@ -1427,127 +1385,12 @@ export function DashboardStoreProvider({ } }; - const openHistory = async () => { - if (!selectedCity) return; - if (!proAccess.subscriptionActive) { - setHistoryState((current) => ({ - ...current, - error: null, - isOpen: true, - loading: false, - recordsLoading: false, - })); - return; - } - const cityName = selectedCity; - const cachedHistory = historyState.dataByCity[cityName]; - const cachedMeta = historyState.metaByCity[cityName]; - - if (cachedMeta && cachedHistory?.length) { - setHistoryState((current) => ({ - ...current, - error: null, - isOpen: true, - loading: false, - recordsLoading: cachedMeta.mode !== "full" && cachedMeta.hasMore, - })); - - if (cachedMeta.mode !== "full" && cachedMeta.hasMore) { - void dashboardClient - .getHistory(cityName, { includeRecords: true }) - .then((payload) => { - if (selectedCityRef.current !== cityName) return; - setHistoryState((current) => ({ - ...current, - dataByCity: { - ...current.dataByCity, - [cityName]: payload.history, - }, - metaByCity: { - ...current.metaByCity, - [cityName]: toHistoryMeta(payload), - }, - recordsLoading: false, - })); - }) - .catch(() => { - if (selectedCityRef.current !== cityName) return; - setHistoryState((current) => ({ - ...current, - recordsLoading: false, - })); - }); - } - return; - } - - setHistoryState((current) => ({ - ...current, - error: null, - isOpen: true, - loading: true, - recordsLoading: false, - })); - try { - const payload = await dashboardClient.getHistory(cityName); - setHistoryState((current) => ({ - ...current, - dataByCity: { - ...current.dataByCity, - [cityName]: payload.history, - }, - metaByCity: { - ...current.metaByCity, - [cityName]: toHistoryMeta(payload), - }, - loading: false, - recordsLoading: payload.has_more === true, - })); - - if (payload.has_more) { - void dashboardClient - .getHistory(cityName, { includeRecords: true }) - .then((fullPayload) => { - if (selectedCityRef.current !== cityName) return; - setHistoryState((current) => ({ - ...current, - dataByCity: { - ...current.dataByCity, - [cityName]: fullPayload.history, - }, - metaByCity: { - ...current.metaByCity, - [cityName]: toHistoryMeta(fullPayload), - }, - recordsLoading: false, - })); - }) - .catch(() => { - if (selectedCityRef.current !== cityName) return; - setHistoryState((current) => ({ - ...current, - recordsLoading: false, - })); - }); - } - } catch (error) { - setHistoryState((current) => ({ - ...current, - error: String(error), - loading: false, - recordsLoading: false, - })); - } - }; - const closeFutureModal = () => { modalOpenSeqRef.current += 1; setFutureModalDate(null); setForecastModalMode(null); }; - const closeHistory = () => - setHistoryState((current) => ({ ...current, isOpen: false })); const openFutureModal = async (dateStr: string, forceRefresh = false) => { mapStopMotionRef.current(); @@ -1679,7 +1522,6 @@ export function DashboardStoreProvider({ citySummariesByName, clearCityFocus, closeFutureModal, - closeHistory, closePanel: () => { setIsPanelOpen(false); }, @@ -1688,14 +1530,12 @@ export function DashboardStoreProvider({ focusCity, forecastModalMode, futureModalDate, - historyState, isPanelOpen, loadCities, preloadCityFromRow, loadingState, proAccess, openFutureModal, - openHistory, openTodayModal, registerMapStopMotion: (stopMotion: () => void) => { mapStopMotionRef.current = stopMotion; @@ -1716,7 +1556,6 @@ export function DashboardStoreProvider({ citySummariesByName, forecastModalMode, futureModalDate, - historyState, isPanelOpen, loadingState, proAccess, @@ -1784,14 +1623,6 @@ export function DashboardStoreProvider({ setForecastDate, ], ); - const dashboardHistoryValue = useMemo( - () => ({ - closeHistory, - historyState, - openHistory, - }), - [closeHistory, historyState, openHistory], - ); const dashboardProAccessValue = useMemo( () => ({ proAccess, @@ -1805,13 +1636,11 @@ export function DashboardStoreProvider({ - - - - {children} - - - + + + {children} + + @@ -1859,16 +1688,6 @@ export function useDashboardModal() { return context; } -export function useDashboardHistory() { - const context = useContext(DashboardHistoryContext); - if (!context) { - throw new Error( - "useDashboardHistory must be used within DashboardStoreProvider", - ); - } - return context; -} - export function useProAccess() { const context = useContext(DashboardProAccessContext); if (!context) { @@ -1899,20 +1718,3 @@ export function useCityData(name?: string | null) { selection.selectedCity === key, }; } - -export function useHistoryData(name?: string | null) { - const history = useDashboardHistory(); - const selection = useDashboardSelection(); - const key = name || selection.selectedCity; - return { - data: key - ? history.historyState.dataByCity[key] || ([] as HistoryPoint[]) - : [], - error: history.historyState.error, - isLoading: history.historyState.loading, - isOpen: history.historyState.isOpen, - isRecordsLoading: history.historyState.recordsLoading, - meta: key ? history.historyState.metaByCity[key] || null : null, - }; -} - diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index f422f674..5ffaddd6 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -4,7 +4,6 @@ import { CityDetail, CityListItem, CitySummary, - HistoryPayload, MarketScan, ScanTerminalFilters, ScanTerminalResponse, @@ -20,7 +19,6 @@ const CACHE_TTL_MS = 30 * 60 * 1000; const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000; const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000; const pendingCityDetailRequests = new Map>(); -const pendingHistoryRequests = new Map>(); const pendingCitySummaryRequests = new Map>(); const pendingCityMarketScanRequests = new Map< string, @@ -452,43 +450,6 @@ export const dashboardClient = { return request; }, - async getHistory(cityName: string, options?: { includeRecords?: boolean }) { - const includeRecords = options?.includeRecords === true; - const requestKey = `${normalizeCityName(cityName)}::${ - includeRecords ? "full" : "preview" - }`; - const existing = pendingHistoryRequests.get(requestKey); - if (existing) { - return existing; - } - - const params = new URLSearchParams(); - if (includeRecords) { - params.set("include_records", "true"); - } - - const request = fetchJson( - `/api/history/${normalizeCityName(cityName)}${ - params.size ? `?${params.toString()}` : "" - }`, - ) - .then((data) => ({ - ...data, - full_count: Number(data.full_count || 0), - has_more: data.has_more === true, - history: Array.isArray(data.history) ? data.history : [], - mode: (data.mode === "full" ? "full" : "preview") as - | "full" - | "preview", - preview_count: Number(data.preview_count || 0), - })) - .finally(() => { - pendingHistoryRequests.delete(requestKey); - }); - - pendingHistoryRequests.set(requestKey, request); - return request; - }, isCityDetailFresh(meta?: CityCacheMeta | null) { return isFresh(meta); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 23f0e706..259ecb53 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -976,78 +976,13 @@ export interface AmosData { observation_time_local?: string | null; } -export interface HistoryPoint { - date: string; - actual: number | null; - deb: number | null; - mu?: number | null; - mgm?: number | null; - forecasts?: Record; - model_reference?: { - available?: boolean; - truth_layer?: string | null; - reference_layer?: string | null; - deb?: { - value?: number | null; - error?: number | null; - }; - models?: Array<{ - model?: string | null; - value?: number | null; - error?: number | null; - participates_in_deb?: boolean; - }>; - model_count?: number | null; - }; - settlement_source?: string | null; - settlement_station_code?: string | null; - settlement_station_label?: string | null; - truth_version?: string | null; - updated_by?: string | null; - truth_updated_at?: number | null; - actual_peak_time?: string | null; - deb_at_peak_minus_12h?: number | null; - deb_at_peak_minus_12h_time?: string | null; - deb_at_peak_minus_12h_error?: number | null; -} - -export interface HistoryPayloadMeta { - mode: "preview" | "full"; - hasMore: boolean; - fullCount: number; - previewCount: number; - settlementSource?: string | null; - settlementSourceLabel?: string | null; -} - -export interface HistoryPayload { - history: HistoryPoint[]; - has_more?: boolean; - full_count?: number; - preview_count?: number; - mode?: "preview" | "full"; - settlement_source?: string | null; - settlement_source_label?: string | null; -} export interface LoadingState { cities: boolean; cityDetail: boolean; - refresh: boolean; - history: boolean; - marketScan?: boolean; - futureDeep?: boolean; - historyRecords?: boolean; -} + refresh: boolean; marketScan?: boolean; + futureDeep?: boolean;} -export interface HistoryState { - isOpen: boolean; - loading: boolean; - recordsLoading: boolean; - error: string | null; - dataByCity: Record; - metaByCity: Record; -} export interface ProAccessState { loading: boolean; @@ -1073,6 +1008,5 @@ export interface DashboardState { selectedForecastDate: string | null; forecastModalMode: ForecastModalMode | null; loadingState: LoadingState; - historyState: HistoryState; proAccess: ProAccessState; } diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 4c0f9b07..767cc089 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -2,7 +2,6 @@ import { Locale } from "@/lib/i18n"; import { AiAnalysisStructured, CityDetail, - HistoryPoint, NearbyStation, } from "@/lib/dashboard-types"; import { @@ -1715,142 +1714,6 @@ export function getShortTermNowcastLines( return rows; } -export function getHistorySummary( - history: HistoryPoint[], - cityLocalDate?: string | null, -) { - const toFinite = (value: unknown): number | null => { - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : null; - }; - const isExcludedModel = (name: string) => - String(name || "").toLowerCase().includes("meteoblue"); - - const cutoff = new Date(); - cutoff.setHours(0, 0, 0, 0); - cutoff.setDate(cutoff.getDate() - 14); - - const recentData = history.filter((row) => { - if (!row?.date) return false; - const rowDate = new Date(`${row.date}T00:00:00`); - return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff; - }); - - const settledData = recentData.filter((row) => { - if (!row?.date) return false; - return cityLocalDate - ? row.date < cityLocalDate - : row.date < new Date().toISOString().slice(0, 10); - }); - const comparableSettledData = settledData.filter((row) => { - const actual = toFinite(row.actual); - const deb = toFinite(row.deb); - return actual != null && deb != null; - }); - - let hits = 0; - const debErrors: number[] = []; - const modelErrors: Record = {}; - - comparableSettledData.forEach((row) => { - const actual = toFinite(row.actual); - const deb = toFinite(row.deb); - if (actual == null || deb == null) return; - debErrors.push(Math.abs(actual - deb)); - if (wuRound(actual) === wuRound(deb)) { - hits += 1; - } - - const forecasts = row.forecasts || {}; - Object.entries(forecasts).forEach(([modelName, modelValue]) => { - if (isExcludedModel(modelName)) return; - const mv = toFinite(modelValue); - if (actual == null || mv == null) return; - if (!modelErrors[modelName]) { - modelErrors[modelName] = []; - } - modelErrors[modelName].push(Math.abs(actual - mv)); - }); - }); - - const modelMaeList = Object.entries(modelErrors) - .map(([name, errors]) => ({ - mae: - errors.length > 0 - ? errors.reduce((sum, value) => sum + value, 0) / errors.length - : Number.POSITIVE_INFINITY, - model: name, - sampleCount: errors.length, - })) - .filter((row) => Number.isFinite(row.mae) && row.sampleCount > 0) - .sort((a, b) => a.mae - b.mae); - - const primaryModelMaeList = modelMaeList.filter((row) => row.sampleCount >= 2); - const bestModel = (primaryModelMaeList[0] || modelMaeList[0]) ?? null; - const bestModelName = bestModel?.model || null; - const bestModelMae = bestModel ? Number(bestModel.mae.toFixed(1)) : null; - const bestModelSeries = recentData.map((row) => - bestModelName ? toFinite(row.forecasts?.[bestModelName]) : null, - ); - - let debWinDaysVsBest = 0; - let debVsBestComparableDays = 0; - if (bestModelName) { - comparableSettledData.forEach((row) => { - const actual = toFinite(row.actual); - const deb = toFinite(row.deb); - const bestModelVal = toFinite(row.forecasts?.[bestModelName]); - if (actual == null || deb == null || bestModelVal == null) return; - debVsBestComparableDays += 1; - if (Math.abs(deb - actual) <= Math.abs(bestModelVal - actual)) { - debWinDaysVsBest += 1; - } - }); - } - - const mgmSettledCount = settledData.reduce((count, row) => { - return toFinite(row.mgm) != null ? count + 1 : count; - }, 0); - const mgmSeriesComplete = - settledData.length >= 2 && mgmSettledCount === settledData.length; - const mgmSeries = mgmSeriesComplete - ? recentData.map((row) => row.mgm ?? null) - : recentData.map(() => null); - - return { - dates: recentData.map((row) => row.date), - debMae: debErrors.length - ? Number( - ( - debErrors.reduce((sum, value) => sum + value, 0) / debErrors.length - ).toFixed(1), - ) - : null, - debs: recentData.map((row) => row.deb), - bestModelName, - bestModelMae, - bestModelSeries, - modelMaeRanks: modelMaeList.map((row) => ({ - model: row.model, - mae: Number(row.mae.toFixed(1)), - sampleCount: row.sampleCount, - })), - debWinDaysVsBest, - debVsBestComparableDays, - debWinRateVsBest: - debVsBestComparableDays > 0 - ? Number(((debWinDaysVsBest / debVsBestComparableDays) * 100).toFixed(0)) - : null, - hitRate: debErrors.length - ? Number(((hits / debErrors.length) * 100).toFixed(0)) - : null, - mgmSeriesComplete, - mgms: mgmSeries, - recentData, - settledCount: comparableSettledData.length, - actuals: recentData.map((row) => row.actual), - }; -} function toFiniteNumber(value: unknown): number | null { const numeric = Number(value); diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 4f98d8e1..760e5329 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -44,7 +44,6 @@ const MESSAGES: Record> = { "detail.closeAria": "关闭城市详情面板", "detail.waitSelect": "等待选择城市", "detail.todayAnalysis": "今日日内分析", - "detail.history": "历史对账", "detail.loading": "正在加载城市详情...", "detail.emptyHint": "从左侧城市列表选择一个城市查看详情。", "detail.sceneryAlt": "{city} 风景照", @@ -72,22 +71,6 @@ const MESSAGES: Record> = { "guide.footer": "数据源以 METAR、香港天文台(HKO)、NOAA 指定站点、Turkish MGM、Open-Meteo、weather.gov 为主。", - "history.title": "📊 历史准确率对账 - {city}", - "history.closeAria": "关闭历史对账", - "history.loading": "正在获取历史数据...", - "history.error": "获取历史信息失败", - "history.empty": "近 15 天暂无该城市历史数据", - "history.previewTitle": "历史准确率对账", - "history.previewDesc": "对比 DEB 预报与实际结算温度,查看命中率、MAE 和模型对比。升级 Pro 即可解锁。", - "history.hitRate": "DEB 结算胜率 (METAR)", - "history.mae": "DEB MAE", - "history.debHitRate": "DEB 结算胜率 (METAR)", - "history.debMae": "DEB MAE", - "history.muMae": "μ MAE", - "history.bestModelMae": "最佳单模型 MAE", - "history.debVsBest": "DEB 优于最佳模型", - "history.sample": "近 15 天已结算样本", - "history.sampleDays": "{count} 天", "future.todayTitle": "{city} · 今日日内分析", "future.dateTitle": "{city} · {date} 未来日期分析", @@ -242,7 +225,6 @@ const MESSAGES: Record> = { "detail.closeAria": "Close city detail panel", "detail.waitSelect": "Waiting for city selection", "detail.todayAnalysis": "Today's Intraday", - "detail.history": "History Reconciliation", "detail.loading": "Loading city details...", "detail.emptyHint": "Select a city from the left list to view details.", "detail.sceneryAlt": "{city} scenery", @@ -271,22 +253,6 @@ const MESSAGES: Record> = { "guide.footer": "Primary data sources are METAR, Hong Kong Observatory (HKO), designated NOAA stations, Turkish MGM, Open-Meteo, and weather.gov.", - "history.title": "📊 Historical Reconciliation - {city}", - "history.closeAria": "Close history reconciliation", - "history.loading": "Loading historical data...", - "history.error": "Failed to load historical data", - "history.empty": "No historical records for this city in the last 15 days", - "history.previewTitle": "Historical Reconciliation", - "history.previewDesc": "Compare DEB forecasts to actual settlement temperatures with hit rate, MAE, and model comparison. Unlock Pro to access.", - "history.hitRate": "DEB Settlement Hit Rate (METAR)", - "history.mae": "DEB MAE", - "history.debHitRate": "DEB Settlement Hit Rate (METAR)", - "history.debMae": "DEB MAE", - "history.muMae": "μ MAE", - "history.bestModelMae": "Best Single-model MAE", - "history.debVsBest": "DEB vs Best Model", - "history.sample": "Settled Samples (Last 15 Days)", - "history.sampleDays": "{count} days", "future.todayTitle": "{city} · Intraday Analysis", "future.dateTitle": "{city} · {date} Future-date Analysis", diff --git a/frontend/middleware.ts b/frontend/middleware.ts index e9028688..4d421b41 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -190,7 +190,6 @@ export const config = { "/api/ops/:path*", "/api/payments/:path*", "/api/system/:path*", - "/api/history/:path*", "/api/city/:path*/detail:path*", "/api/scan/terminal/ai:path*", ], diff --git a/scripts/scrub_secrets.py b/scripts/scrub_secrets.py new file mode 100644 index 00000000..914d8152 --- /dev/null +++ b/scripts/scrub_secrets.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +"""Scrub secrets from the codebase for GitHub-safe public reference. + +Run this on the public/github-safe branch before committing. +It redacts hardcoded API keys, tokens, and replaces .env files. +""" + +from __future__ import annotations + +import re +import os +import shutil +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Patterns to redact (value -> REDACTED) +SECRET_PATTERNS: list[tuple[str, str]] = [ + # Hardcoded tokens in settlement_sources.py + ( + r'IMGW_METEO_API_TOKEN\s*=\s*"[^"]*"', + 'IMGW_METEO_API_TOKEN = os.environ.get("IMGW_METEO_API_TOKEN", "")', + ), + ( + r'NOAA_WRH_MESO_TOKEN\s*=\s*"[^"]*"', + 'NOAA_WRH_MESO_TOKEN = os.environ.get("NOAA_WRH_MESO_TOKEN", "")', + ), + # mesoToken in tmp files + ( + r"var\s+mesoToken\s*=\s*'[^']*'", + "var mesoToken = 'REDACTED'", + ), + ( + r"mesoToken\s*=\s*'[^']*'", + "mesoToken = 'REDACTED'", + ), + # Any bare synopticdata URLs with tokens + ( + r"https://api\.synopticdata\.com/v2/stations/timeseries\?[^\"'\s]*token=[^\"'&\s]+", + "https://api.synopticdata.com/v2/stations/timeseries?token=REDACTED", + ), +] + +# Files to delete entirely (temp files with secrets) +DELETE_FILES: list[str] = [ + "tmp_apikey.js", + "tmp_obs.js", +] + +# Files to replace with .example counterparts +REPLACE_WITH_EXAMPLE: dict[str, str] = { + ".env": ".env.example", + ".env.local": ".env.example", + "frontend/.env.local": "frontend/.env.example", +} + + +def scrub_file(path: Path) -> bool: + """Apply secret patterns to a single file. Returns True if changed.""" + try: + content = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, PermissionError): + return False + + original = content + for pattern, replacement in SECRET_PATTERNS: + content = re.sub(pattern, replacement, content) + + if content != original: + path.write_text(content, encoding="utf-8") + print(f" SCRUBBED: {path.relative_to(REPO_ROOT)}") + return True + return False + + +def main() -> None: + print("=== Scrubbing secrets for GitHub-safe reference ===\n") + + changes = 0 + + # 1. Delete known temp files with secrets + for fname in DELETE_FILES: + fpath = REPO_ROOT / fname + if fpath.exists(): + fpath.unlink() + print(f" DELETED: {fname}") + changes += 1 + + # 2. Replace .env files with .example + for target, example in REPLACE_WITH_EXAMPLE.items(): + target_path = REPO_ROOT / target + example_path = REPO_ROOT / example + if target_path.exists() and example_path.exists(): + shutil.copy2(example_path, target_path) + print(f" REPLACED: {target} -> {example}") + changes += 1 + elif target_path.exists() and not example_path.exists(): + target_path.unlink() + print(f" DELETED: {target} (no .example to replace with)") + changes += 1 + + # 3. Scrub hardcoded secrets in source files + src_dirs = [ + REPO_ROOT / "src", + REPO_ROOT / "web", + REPO_ROOT / "frontend", + REPO_ROOT / "scripts", + ] + for src_dir in src_dirs: + if not src_dir.is_dir(): + continue + for fpath in src_dir.rglob("*.py"): + if scrub_file(fpath): + changes += 1 + for fpath in src_dir.rglob("*.js"): + if scrub_file(fpath): + changes += 1 + for fpath in src_dir.rglob("*.ts"): + if scrub_file(fpath): + changes += 1 + for fpath in src_dir.rglob("*.tsx"): + if scrub_file(fpath): + changes += 1 + + # 4. Also check root-level configs + for fpath in REPO_ROOT.glob("*.yaml"): + if scrub_file(fpath): + changes += 1 + for fpath in REPO_ROOT.glob("*.yml"): + if scrub_file(fpath): + changes += 1 + for fpath in REPO_ROOT.glob("*.json"): + if scrub_file(fpath): + changes += 1 + + print(f"\n=== Done: {changes} changes made ===") + + +if __name__ == "__main__": + main() diff --git a/src/analysis/deb_algorithm.py b/src/analysis/deb_algorithm.py index c43a1249..d8cf969c 100644 --- a/src/analysis/deb_algorithm.py +++ b/src/analysis/deb_algorithm.py @@ -61,8 +61,7 @@ def _sf(value): def _is_excluded_model_name(model_name: str) -> bool: - normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "") - return "meteoblue" in normalized + return False def _normalize_deb_model_name(model_name: str) -> str: diff --git a/src/data_collection/settlement_sources.py b/src/data_collection/settlement_sources.py index 03441e84..8934dee3 100644 --- a/src/data_collection/settlement_sources.py +++ b/src/data_collection/settlement_sources.py @@ -2,6 +2,7 @@ from __future__ import annotations import csv import math +import os import threading import time from concurrent.futures import ThreadPoolExecutor @@ -22,8 +23,8 @@ _official_intraday_repo = OfficialIntradayObservationRepository() class SettlementSourceMixin: IMGW_METEO_API_BASE = "https://meteo.imgw.pl/api/v1" - IMGW_METEO_API_TOKEN = "p4DXKjsYadfBV21TYrDk" - NOAA_WRH_MESO_TOKEN = "7c76618b66c74aee913bdbae4b448bdd" + IMGW_METEO_API_TOKEN = os.environ.get("IMGW_METEO_API_TOKEN", "") + NOAA_WRH_MESO_TOKEN = os.environ.get("NOAA_WRH_MESO_TOKEN", "") NOAA_WRH_TIMESERIES_REFERER_BASE = "https://www.weather.gov/wrh/timeseries?site=" def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]: diff --git a/src/database/db_manager.py b/src/database/db_manager.py index dd8a20e1..b0754bba 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -307,16 +307,6 @@ class DBManager: source_fingerprint TEXT ) """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS city_history_preview_cache ( - city TEXT PRIMARY KEY, - payload_json TEXT NOT NULL, - updated_at TEXT NOT NULL, - updated_at_ts REAL NOT NULL, - version TEXT, - source_fingerprint TEXT - ) - """) conn.execute(""" CREATE TABLE IF NOT EXISTS cache_refresh_locks ( cache_key TEXT PRIMARY KEY, @@ -430,8 +420,6 @@ class DBManager: return "city_nearby_cache" if normalized == "market": return "city_market_cache" - if normalized == "history_preview": - return "city_history_preview_cache" return None def get_city_cache(self, kind: str, city: str) -> Optional[Dict[str, Any]]: diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 6f966be2..c3216d73 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -6,7 +6,7 @@ import web.routes as routes import web.scan_terminal_cache as scan_terminal_cache import web.scan_terminal_service as scan_terminal_service from web.scan_terminal_cache import scan_terminal_cache_key -from src.database.runtime_state import TruthRecordRepository, TrainingFeatureRecordRepository +from src.database.runtime_state import TruthRecordRepository client = TestClient(app) @@ -806,49 +806,3 @@ def test_scan_terminal_cache_key_includes_filter_dimensions(): assert first != second assert "trend" in second assert "week" in second - - -def test_city_history_is_read_only_and_uses_sqlite_truth_and_features(monkeypatch): - monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) - - def _fail_bootstrap(*args, **kwargs): - raise AssertionError("bootstrap should not be called by /api/history") - - monkeypatch.setattr(routes, "bootstrap_recent_daily_history_if_missing", _fail_bootstrap, raising=False) - - truth_repo = TruthRecordRepository() - truth_repo.upsert_truth( - city="ankara", - target_date="2026-04-02", - actual_high=16.0, - settlement_source="metar", - settlement_station_code="LTAC", - settlement_station_label="Ankara Esenboga Airport", - truth_version="v1", - updated_by="test", - source_payload={"sample": True}, - is_final=True, - ) - feature_repo = TrainingFeatureRecordRepository() - feature_repo.upsert_record( - "ankara", - "2026-04-02", - { - "deb_prediction": 16.4, - "mu": 16.2, - "forecasts": {"Open-Meteo": 15.8, "ECMWF": 16.1}, - }, - ) - - response = client.get("/api/history/ankara") - - assert response.status_code == 200 - payload = response.json() - assert payload["history"] - row = next(item for item in payload["history"] if item["date"] == "2026-04-02") - assert row["actual"] == 16.0 - assert row["deb"] == 16.4 - assert row["mu"] == 16.2 - assert row["forecasts"]["Open-Meteo"] == 15.8 - assert row["settlement_station_code"] == "LTAC" - assert row["truth_version"] == "v1" diff --git a/web/analysis_service.py b/web/analysis_service.py index 3ed515e9..30ad6c91 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -29,14 +29,6 @@ from src.data_collection.city_registry import ALIASES, CITY_REGISTRY from src.data_collection.city_time import get_city_utc_offset_seconds from src.data_collection.nmc_sources import NMC_CITY_REFERENCES from src.database.runtime_state import IntradayPathSnapshotRepository -from web.services.groq_commentary import ( - build_groq_commentary_context as _groq_context_builder, - clean_commentary_text as _groq_clean_text, - groq_commentary_enabled as _groq_enabled, - maybe_enrich_dynamic_commentary_with_groq as _groq_enrich, - normalize_groq_commentary_payload as _groq_normalize_payload, - request_groq_commentary as _groq_request, -) from web.services.city_payloads import ( build_city_detail_payload as _city_payload_detail, build_city_market_scan_payload as _city_payload_market_scan, @@ -475,31 +467,11 @@ def _set_cached_summary(city: str, payload: Dict[str, Any]) -> None: _SUMMARY_CACHE[city] = {"t": _time.time(), "d": dict(payload)} -def _groq_commentary_enabled() -> bool: - return _groq_enabled() - - -def _clean_commentary_text(value: Any, *, limit: int = 240) -> str: - return _groq_clean_text(value, limit=limit) - - -def _build_groq_commentary_context(result: Dict[str, Any]) -> Dict[str, Any]: - return _groq_context_builder(result) - - -def _normalize_groq_commentary_payload(payload: Dict[str, Any]) -> Dict[str, Any]: - return _groq_normalize_payload(payload) - - -def _request_groq_commentary(context: Dict[str, Any]) -> Optional[Dict[str, Any]]: - return _groq_request(context) - - def _maybe_enrich_dynamic_commentary_with_groq( - city: str, + _city: str, result: Dict[str, Any], ) -> Dict[str, Any]: - return _groq_enrich(city, result) + return result.get("dynamic_commentary") or {"summary": "", "notes": []} diff --git a/web/core.py b/web/core.py index 34e11f19..b6b73657 100644 --- a/web/core.py +++ b/web/core.py @@ -405,8 +405,7 @@ def _sf(v) -> Optional[float]: def _is_excluded_model_name(model_name: str) -> bool: - normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "") - return "meteoblue" in normalized + return False def _sqlite_health() -> Dict[str, Any]: @@ -464,7 +463,6 @@ def _integration_summary() -> Dict[str, Any]: weather_cfg = _config.get("weather", {}) if isinstance(_config, dict) else {} return { "supabase_configured": bool(SUPABASE_ENTITLEMENT.configured), - "meteoblue_configured": bool(os.getenv("METEOBLUE_API_KEY")), "telegram_bot_configured": bool((_config.get("telegram", {}) or {}).get("bot_token")), "walletconnect_configured": bool(os.getenv("NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID")), "weather_sources": { diff --git a/web/routers/city.py b/web/routers/city.py index b5c0f494..ef4b9114 100644 --- a/web/routers/city.py +++ b/web/routers/city.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, BackgroundTasks, Request from web.services.city_api import ( get_city_detail_aggregate_payload, get_city_detail_payload, - get_city_history_payload, get_city_market_scan_payload, get_city_summary_payload, list_cities_payload, @@ -37,20 +36,6 @@ async def city_detail( ) -@router.get("/api/history/{name}") -async def city_history( - request: Request, - background_tasks: BackgroundTasks, - name: str, - include_records: bool = False, -): - return await get_city_history_payload( - request, - name, - include_records=include_records, - ) - - @router.get("/api/city/{name}/summary") async def city_summary( request: Request, diff --git a/web/services/city_api.py b/web/services/city_api.py index fd2e123c..2acda5d1 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -109,24 +109,6 @@ async def get_city_detail_payload( return await run_in_threadpool(legacy_routes._analyze, city, force_refresh, False, detail_mode) -async def get_city_history_payload( - request: Request, - name: str, - *, - include_records: bool = False, -) -> Dict[str, Any]: - legacy_routes._assert_entitlement(request) - city = legacy_routes._normalize_city_or_404(name) - if include_records: - return await run_in_threadpool(legacy_routes._build_city_history_payload, city, True) - cached_entry = await run_in_threadpool(legacy_routes._CACHE_DB.get_city_cache, "history_preview", city) - if cached_entry: - if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_HISTORY_PREVIEW_CACHE_TTL_SEC): - return await run_in_threadpool(legacy_routes._refresh_city_history_preview_cache, city) - return cached_entry.get("payload") or {} - return await run_in_threadpool(legacy_routes._refresh_city_history_preview_cache, city) - - async def get_city_summary_payload( _request: Request, name: str, diff --git a/web/services/city_runtime.py b/web/services/city_runtime.py index 2f29c6af..dfbbdbfd 100644 --- a/web/services/city_runtime.py +++ b/web/services/city_runtime.py @@ -2,7 +2,7 @@ from __future__ import annotations import os import time -from datetime import datetime, timedelta +from datetime import datetime from typing import Optional from fastapi import APIRouter, BackgroundTasks, HTTPException @@ -13,8 +13,7 @@ from src.database.db_manager import DBManager from src.database.runtime_state import ( DailyRecordRepository, STATE_STORAGE_SQLITE, - TrainingFeatureRecordRepository, - TruthRecordRepository, + TruthRecordRepository, # noqa: F401 - compatibility export for ops/truth-history get_state_storage_mode, ) from src.analysis.settlement_rounding import apply_city_settlement @@ -40,7 +39,7 @@ from web.core import ( CITY_RISK_PROFILES, # noqa: F401 - compatibility export for tests and transitional routers PAYMENT_CHECKOUT, # noqa: F401 - compatibility export for tests and transitional routers PaymentCheckoutError, # noqa: F401 - compatibility export for tests and transitional routers - SETTLEMENT_SOURCE_LABELS, + SETTLEMENT_SOURCE_LABELS, # noqa: F401 - compatibility export for city list payloads SUPABASE_ENTITLEMENT, # noqa: F401 - compatibility export for tests and transitional routers ConfirmPaymentTxRequest, # noqa: F401 - compatibility export for tests and transitional routers CreatePaymentIntentRequest, # noqa: F401 - compatibility export for tests and transitional routers @@ -58,7 +57,6 @@ from web.core import ( _resolve_auth_points, # noqa: F401 - compatibility export for tests and transitional routers _resolve_weekly_profile, # noqa: F401 - compatibility export for tests and transitional routers _sf, - _is_excluded_model_name, ) router = APIRouter() @@ -67,8 +65,6 @@ _CACHE_DB = DBManager() _DEB_RECENT_LOOKBACK = 7 _DEB_RECENT_MIN_SAMPLES = 3 _daily_record_repo = DailyRecordRepository() -_truth_record_repo = TruthRecordRepository() -_training_feature_repo = TrainingFeatureRecordRepository() TRACKABLE_ANALYTICS_EVENTS = { "signup_completed", @@ -100,7 +96,6 @@ DEFAULT_STATUS_CITIES = [ "paris", "madrid", ] -HISTORY_PREVIEW_DAY_LIMIT = 21 ASIA_CORE_CITIES = [ "hong kong", "taipei", @@ -151,10 +146,6 @@ MARKET_SCAN_PAYLOAD_TTL_SEC = max( 5, int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")), ) -CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max( - 60, - int(os.getenv("POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC", "1800")), -) CACHE_REFRESH_LOCK_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CACHE_REFRESH_LOCK_TTL_SEC", "120"))) @@ -328,175 +319,6 @@ def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict: return payload -def _build_history_model_reference( - *, - forecasts: dict, - actual: object, - deb: object, -) -> dict: - """Expose the archived model snapshot as reference evidence, not truth.""" - actual_value = _sf(actual) - deb_value = _sf(deb) - entries = [] - for model_name, model_value in (forecasts or {}).items(): - if _is_excluded_model_name(str(model_name)): - continue - value = _sf(model_value) - if value is None: - continue - error = abs(value - actual_value) if actual_value is not None else None - entries.append( - { - "model": str(model_name), - "value": round(value, 1), - "error": round(error, 1) if error is not None else None, - "participates_in_deb": True, - } - ) - entries.sort( - key=lambda row: ( - row["error"] is None, - row["error"] if row["error"] is not None else 999, - row["model"], - ) - ) - deb_error = abs(deb_value - actual_value) if deb_value is not None and actual_value is not None else None - return { - "available": bool(entries), - "truth_layer": "settlement_actual", - "reference_layer": "archived_model_snapshot", - "deb": { - "value": round(deb_value, 1) if deb_value is not None else None, - "error": round(deb_error, 1) if deb_error is not None else None, - }, - "models": entries, - "model_count": len(entries), - } - - -def _build_city_history_payload(city: str, include_records: bool = False) -> dict: - source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower() - truth_rows = _truth_record_repo.load_city(city) - feature_rows = _training_feature_repo.load_city(city) - - if not truth_rows and not feature_rows: - project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - history_file = os.path.join(project_root, "data", "daily_records.json") - data = load_history(history_file) - city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {} - else: - all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys())) - city_data = {} - for day in all_dates: - record: dict[str, object] = {} - truth = truth_rows.get(day) or {} - features = feature_rows.get(day) or {} - if truth.get("actual_high") is not None: - record["actual_high"] = truth.get("actual_high") - record["settlement_source"] = truth.get("settlement_source") - record["settlement_station_code"] = truth.get("settlement_station_code") - record["settlement_station_label"] = truth.get("settlement_station_label") - record["truth_version"] = truth.get("truth_version") - record["updated_by"] = truth.get("updated_by") - record["truth_updated_at"] = truth.get("truth_updated_at") - if isinstance(features, dict): - if features.get("deb_prediction") is not None: - record["deb_prediction"] = features.get("deb_prediction") - if features.get("mu") is not None: - record["mu"] = features.get("mu") - if isinstance(features.get("forecasts"), dict): - record["forecasts"] = features.get("forecasts") - city_data[day] = record - - if not city_data: - return { - "history": [], - "mode": "full" if include_records else "preview", - "has_more": False, - "full_count": 0, - "preview_count": 0, - "settlement_source": source, - "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()), - } - - all_days = sorted(city_data.keys()) - selected_days = all_days if include_records else all_days[-HISTORY_PREVIEW_DAY_LIMIT:] - out = [] - for day in selected_days: - rec = city_data.get(day, {}) - if not isinstance(rec, dict): - rec = {} - - act = rec.get("actual_high") - deb = rec.get("deb_prediction") - mu = rec.get("mu") - snapshots = _load_snapshot_rows_for_day(city, day) - peak_ref = _build_peak_minus_12h_reference( - actual_high=act, - snapshots=snapshots, - ) - forecasts_raw = rec.get("forecasts", {}) or {} - forecasts = {} - if isinstance(forecasts_raw, dict): - for model_name, model_value in forecasts_raw.items(): - if _is_excluded_model_name(str(model_name)): - continue - fv = _sf(model_value) - forecasts[str(model_name)] = fv if fv is not None else None - forecasts = _merge_missing_history_forecasts_from_snapshots( - forecasts, - snapshots, - ) - model_reference = _build_history_model_reference( - forecasts=forecasts, - actual=act, - deb=deb, - ) - mgm = forecasts.get("MGM") - out.append( - { - "date": day, - "actual": float(act) if act is not None else None, - "deb": float(deb) if deb is not None else None, - "mu": float(mu) if mu is not None else None, - "mgm": float(mgm) if mgm is not None else None, - "forecasts": forecasts, - "model_reference": model_reference, - "settlement_source": rec.get("settlement_source"), - "settlement_station_code": rec.get("settlement_station_code"), - "settlement_station_label": rec.get("settlement_station_label"), - "truth_version": rec.get("truth_version"), - "updated_by": rec.get("updated_by"), - "truth_updated_at": rec.get("truth_updated_at"), - "actual_peak_time": peak_ref.get("actual_peak_time"), - "deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"), - "deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"), - "deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"), - } - ) - - return { - "history": out, - "mode": "full" if include_records else "preview", - "has_more": len(all_days) > len(selected_days), - "full_count": len(all_days), - "preview_count": len(out), - "settlement_source": source, - "settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()), - } - - -def _refresh_city_history_preview_cache(city: str) -> dict: - payload = _build_city_history_payload(city, include_records=False) - _CACHE_DB.set_city_cache( - "history_preview", - city, - payload, - version="v1", - source_fingerprint=f"{city}:history_preview", - ) - return payload - def _schedule_cache_refresh( background_tasks: BackgroundTasks, @@ -507,7 +329,7 @@ def _schedule_cache_refresh( ) -> bool: normalized_kind = str(kind or "").strip().lower() normalized_city = str(city or "").strip().lower() - if normalized_kind not in {"summary", "panel", "nearby", "market", "history_preview"} or not normalized_city: + if normalized_kind not in {"summary", "panel", "nearby", "market"} or not normalized_city: return False cache_key = f"city:{normalized_kind}:{normalized_city}" owner = _CACHE_DB.acquire_cache_refresh_lock( @@ -525,8 +347,6 @@ def _schedule_cache_refresh( _refresh_city_panel_cache(normalized_city, force_refresh=force_refresh) elif normalized_kind == "nearby": _refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh) - elif normalized_kind == "history_preview": - _refresh_city_history_preview_cache(normalized_city) else: _refresh_city_market_cache(normalized_city, force_refresh=force_refresh) except Exception as exc: @@ -544,108 +364,6 @@ def _schedule_cache_refresh( return True -def _parse_snapshot_dt(value: object) -> Optional[datetime]: - raw = str(value or "").strip() - if not raw: - return None - try: - return datetime.fromisoformat(raw.replace("Z", "+00:00")) - except Exception: - return None - - -def _build_peak_minus_12h_reference( - *, - actual_high: object, - snapshots: list[dict], -) -> dict: - actual = _sf(actual_high) - if actual is None or not snapshots: - return {} - - tolerance = 0.11 - normalized = [] - for row in snapshots: - if not isinstance(row, dict): - continue - dt = _parse_snapshot_dt(row.get("timestamp")) - if dt is None: - continue - normalized.append( - { - "dt": dt, - "max_so_far": _sf(row.get("max_so_far")), - "deb_prediction": _sf(row.get("deb_prediction")), - } - ) - if not normalized: - return {} - - peak_row = next( - ( - row - for row in normalized - if row["max_so_far"] is not None and row["max_so_far"] >= actual - tolerance - ), - None, - ) - if peak_row is None: - return {} - - peak_dt = peak_row["dt"] - anchor_dt = peak_dt - timedelta(hours=12) - anchor_row = None - for row in normalized: - if row["dt"] <= anchor_dt and row["deb_prediction"] is not None: - anchor_row = row - elif row["dt"] > anchor_dt: - break - - peak_time = peak_dt.strftime("%H:%M") - result = { - "actual_peak_time": peak_time, - } - if anchor_row and anchor_row["deb_prediction"] is not None: - deb_value = float(anchor_row["deb_prediction"]) - result.update( - { - "deb_at_peak_minus_12h": deb_value, - "deb_at_peak_minus_12h_time": anchor_row["dt"].strftime("%H:%M"), - "deb_at_peak_minus_12h_error": round(deb_value - actual, 1), - } - ) - return result - - -def _merge_missing_history_forecasts_from_snapshots( - forecasts: dict, - snapshots: list[dict], -) -> dict: - merged = dict(forecasts or {}) - if not snapshots: - return merged - - fallback_values: dict[str, Optional[float]] = {} - for row in snapshots: - if not isinstance(row, dict): - continue - multi_model = row.get("multi_model") or {} - if not isinstance(multi_model, dict): - continue - for model_name, model_value in multi_model.items(): - model_key = str(model_name or "").strip() - if not model_key or _is_excluded_model_name(model_key): - continue - parsed = _sf(model_value) - if parsed is not None: - fallback_values[model_key] = parsed - - for model_name, model_value in fallback_values.items(): - existing = _sf(merged.get(model_name)) - if existing is None: - merged[model_name] = model_value - return merged - def _normalize_city_or_404(name: str) -> str: city = name.lower().strip().replace("-", " ") @@ -799,9 +517,5 @@ def _build_recent_deb_performance_index( } return index - -def _load_snapshot_rows_for_day(_city: str, _day: str) -> list: - return [] - __all__ = [name for name in globals() if not (name.startswith('__') and name.endswith('__'))] diff --git a/web/services/groq_commentary.py b/web/services/groq_commentary.py deleted file mode 100644 index b97602e4..00000000 --- a/web/services/groq_commentary.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Groq-backed bilingual commentary enrichment.""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import threading -import time -from typing import Any, Dict, Optional - -import httpx -from loguru import logger - -_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 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 diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 4c887555..8cb6d6e3 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -437,7 +437,9 @@ def get_ops_logs( def get_ops_health_check(request: Request) -> dict[str, Any]: _require_ops(request) - import os, requests as _r, time as _time + import os + import requests as _r + import time as _time results: dict[str, dict] = {} timeout = 3 diff --git a/web/services/system_api.py b/web/services/system_api.py index 61a3bee2..9f9fbcdc 100644 --- a/web/services/system_api.py +++ b/web/services/system_api.py @@ -35,9 +35,7 @@ def get_system_cache_status(request: Request, cities: Optional[str] = None) -> D "summary": legacy_routes.CITY_SUMMARY_CACHE_TTL_SEC, "panel": legacy_routes.CITY_PANEL_CACHE_TTL_SEC, "nearby": legacy_routes.CITY_NEARBY_CACHE_TTL_SEC, - "market": legacy_routes.CITY_MARKET_CACHE_TTL_SEC, - "history_preview": legacy_routes.CITY_HISTORY_PREVIEW_CACHE_TTL_SEC, - } + "market": legacy_routes.CITY_MARKET_CACHE_TTL_SEC, } items = [] for city in selected: row = {"city": city}