移除未使用的 Groq 和 Meteoblue 服务代码及配置

This commit is contained in:
2569718930@qq.com
2026-05-19 00:05:07 +08:00
parent 19bd8f3636
commit b93a75516d
28 changed files with 215 additions and 1417 deletions
-1
View File
@@ -63,7 +63,6 @@ POLYWEATHER_METAR_TIMEOUT_SEC=4
POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5 POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC=3.5
METAR_CACHE_TTL_SEC=600 METAR_CACHE_TTL_SEC=600
JMA_AMEDAS_CACHE_TTL_SEC=120 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. # 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 # Optional: set this to a writable runtime path if you manually deploy a
+42
View File
@@ -1 +1,43 @@
# PolyWeather 前端最小配置(本地 / Vercel)
# 只部署天气看板时,先填下面 4 项即可。
# 必填:后端 FastAPI 基础地址
# 默认供 Next.js API Route 在服务端代理后端使用。
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 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=<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
+2 -6
View File
@@ -21,8 +21,8 @@ PolyWeather Pro 的生产前端工程。
## 当前前端能力 ## 当前前端能力
- 主站 Dashboard 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心 - 主站 Dashboard 支持地图、城市详情、今日日内分析和账户中心
- `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF结算来源和历史对账 - `/docs` 已提供公开双语产品文档中心,解释日内分析、校准概率、模型栈、TAF结算来源
- 今日日内分析支持: - 今日日内分析支持:
- `锚点状态` - `锚点状态`
- `当前节奏` - `当前节奏`
@@ -31,9 +31,6 @@ PolyWeather Pro 的生产前端工程。
- `专业气象结论条` - `专业气象结论条`
- `气象证据链 / 失效条件 / 确认条件` - `气象证据链 / 失效条件 / 确认条件`
- 非香港机场城市的 `TAF` 时段提示与走势图联动 - 非香港机场城市的 `TAF` 时段提示与走势图联动
- 历史对账支持:
- `DEB / 最佳单模型 / 实测最高温` 对比
- 峰值前 12 小时 `DEB` 参考(近似)
- `/ops` 已支持桌面表格 + 手机端卡片化视图 - `/ops` 已支持桌面表格 + 手机端卡片化视图
- 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住 - 点击城市图标后会显示地图顶部同步提醒与详情面板内同步徽标,避免用户误判为卡住
- 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果 - 城市详情会自动识别“单模型 / 单日”的稀疏缓存并主动刷新,避免误把残缺 detail 当作完整结果
@@ -117,7 +114,6 @@ NEXT_PUBLIC_POLYWEATHER_EAGER_CITY_SUMMARIES=false
- `GET /api/city/[name]` - `GET /api/city/[name]`
- `GET /api/city/[name]/summary` - `GET /api/city/[name]/summary`
- `GET /api/city/[name]/detail` - `GET /api/city/[name]/detail`
- `GET /api/history/[name]`
鉴权: 鉴权:
-27
View File
@@ -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,
});
}
@@ -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 (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
+1 -3
View File
@@ -31,9 +31,7 @@ export function MapCanvas({
}, },
selectedCity: store.selectedCity, selectedCity: store.selectedCity,
selectedDetail: store.selectedDetail, selectedDetail: store.selectedDetail,
suspendMotion: suspendMotion: Boolean(store.futureModalDate),
Boolean(store.futureModalDate) ||
store.historyState.isOpen,
isLoadingDetail: store.loadingState.cityDetail, isLoadingDetail: store.loadingState.cityDetail,
}); });
@@ -343,7 +343,6 @@ function getRoundedModelVoteDistribution(
Object.entries(view.models || {}).forEach(([name, rawValue]) => { Object.entries(view.models || {}).forEach(([name, rawValue]) => {
const normalized = normalizeModelNameForVote(name); const normalized = normalizeModelNameForVote(name);
if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
const value = Number(rawValue); const value = Number(rawValue);
if (!Number.isFinite(value)) return; if (!Number.isFinite(value)) return;
const family = getModelVoteFamily(name); const family = getModelVoteFamily(name);
+1 -6
View File
@@ -12,12 +12,7 @@ export const DOCS_GROUPS: DocsNavGroup[] = [
{ {
id: "settlement", id: "settlement",
title: { "zh-CN": "结算与数据", "en-US": "Settlement & Data" }, title: { "zh-CN": "结算与数据", "en-US": "Settlement & Data" },
}, },];
{
id: "history",
title: { "zh-CN": "历史对账", "en-US": "History & Reconciliation" },
},
];
export function getDocsGroupTitle(groupId: DocsNavGroup["id"], locale: DocsLocale) { export function getDocsGroupTitle(groupId: DocsNavGroup["id"], locale: DocsLocale) {
return DOCS_GROUPS.find((group) => group.id === groupId)?.title[locale] || groupId; return DOCS_GROUPS.find((group) => group.id === groupId)?.title[locale] || groupId;
+6 -82
View File
@@ -22,7 +22,7 @@ export interface DocsPageContent {
export interface DocsPageMeta { export interface DocsPageMeta {
slug: string; slug: string;
group: "getting-started" | "analysis" | "settlement" | "history"; group: "getting-started" | "analysis" | "settlement";
} }
export interface DocsPage extends DocsPageMeta { export interface DocsPage extends DocsPageMeta {
@@ -55,7 +55,7 @@ export const DOCS_PAGES: DocsPage[] = [
id: "core-modules", id: "core-modules",
title: "你会在页面上看到什么", title: "你会在页面上看到什么",
blocks: [ 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", id: "core-modules",
title: "What you see on the site", title: "What you see on the site",
blocks: [ 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", slug: "extension",
group: "getting-started", group: "getting-started",
@@ -557,7 +481,7 @@ export const DOCS_PAGES: DocsPage[] = [
type: "link", type: "link",
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb", href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
label: "打开 Chrome Web Store", label: "打开 Chrome Web Store",
caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析与历史对账。", caption: "安装插件后,可在侧边栏里快速跳回主站的今日日内分析。",
}, },
], ],
}, },
@@ -597,7 +521,7 @@ export const DOCS_PAGES: DocsPage[] = [
blocks: [ blocks: [
{ {
type: "paragraph", type: "paragraph",
text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断、历史对账和完整交易语境仍以主站为准。", text: "插件不承担完整分析体验,也不承载支付链路。复杂结构判断和完整交易语境仍以主站为准。",
}, },
{ {
type: "callout", type: "callout",
@@ -631,7 +555,7 @@ export const DOCS_PAGES: DocsPage[] = [
type: "link", type: "link",
href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb", href: "https://chromewebstore.google.com/detail/mhndjbgjljjfcfkojhmhpfcbconnikne?utm_source=item-share-cb",
label: "Open Chrome Web Store", 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.",
}, },
], ],
}, },
+5 -203
View File
@@ -28,10 +28,6 @@ import {
CitySummary, CitySummary,
DashboardState, DashboardState,
ForecastModalMode, ForecastModalMode,
HistoryPoint,
HistoryPayload,
HistoryPayloadMeta,
HistoryState,
LoadingState, LoadingState,
ProAccessState, ProAccessState,
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
@@ -39,7 +35,6 @@ import {
interface DashboardStoreValue extends DashboardState { interface DashboardStoreValue extends DashboardState {
clearCityFocus: () => void; clearCityFocus: () => void;
closeFutureModal: () => void; closeFutureModal: () => void;
closeHistory: () => void;
closePanel: () => void; closePanel: () => void;
ensureCityDetail: ( ensureCityDetail: (
cityName: string, cityName: string,
@@ -61,7 +56,6 @@ interface DashboardStoreValue extends DashboardState {
loadCities: () => Promise<void>; loadCities: () => Promise<void>;
preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void; preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>; openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>; openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void; registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>; refreshAll: () => Promise<void>;
@@ -84,10 +78,6 @@ type DashboardModalContextValue = Pick<
| "selectedForecastDate" | "selectedForecastDate"
| "setForecastDate" | "setForecastDate"
>; >;
type DashboardHistoryContextValue = Pick<
DashboardStoreValue,
"closeHistory" | "historyState" | "openHistory"
>;
type DashboardProAccessContextValue = Pick< type DashboardProAccessContextValue = Pick<
DashboardStoreValue, DashboardStoreValue,
"proAccess" | "refreshProAccess" "proAccess" | "refreshProAccess"
@@ -100,8 +90,6 @@ const DashboardActionsContext = createContext<Pick<
> | null>(null); > | null>(null);
const DashboardModalContext = const DashboardModalContext =
createContext<DashboardModalContextValue | null>(null); createContext<DashboardModalContextValue | null>(null);
const DashboardHistoryContext =
createContext<DashboardHistoryContextValue | null>(null);
const DashboardProAccessContext = const DashboardProAccessContext =
createContext<DashboardProAccessContextValue | null>(null); createContext<DashboardProAccessContextValue | null>(null);
const DashboardSelectionContext = createContext<Pick< const DashboardSelectionContext = createContext<Pick<
@@ -126,24 +114,11 @@ function getInitialLoadingState(): LoadingState {
cities: false, cities: false,
cityDetail: false, cityDetail: false,
futureDeep: false, futureDeep: false,
history: false,
historyRecords: false,
refresh: false, refresh: false,
marketScan: false, marketScan: false,
}; };
} }
function getInitialHistoryState(): HistoryState {
return {
dataByCity: {},
error: null,
isOpen: false,
loading: false,
metaByCity: {},
recordsLoading: false,
};
}
function getInitialProAccessState(): ProAccessState { function getInitialProAccessState(): ProAccessState {
if (isBrowserLocalFullAccess()) { if (isBrowserLocalFullAccess()) {
return getLocalDevProAccessState(); return getLocalDevProAccessState();
@@ -666,20 +641,6 @@ function mergeMarketScan(
}; };
} }
function toHistoryMeta(payload: HistoryPayload): HistoryPayloadMeta {
const history = Array.isArray(payload.history) ? payload.history : [];
const previewCount = Number(payload.preview_count || history.length || 0);
const fullCount = Number(payload.full_count || previewCount || 0);
return {
mode: payload.mode === "full" ? "full" : "preview",
hasMore: payload.has_more === true,
fullCount,
previewCount,
settlementSource: payload.settlement_source ?? null,
settlementSourceLabel: payload.settlement_source_label ?? null,
};
}
export function DashboardStoreProvider({ export function DashboardStoreProvider({
children, children,
}: { }: {
@@ -710,9 +671,6 @@ export function DashboardStoreProvider({
const [loadingState, setLoadingState] = useState<LoadingState>( const [loadingState, setLoadingState] = useState<LoadingState>(
getInitialLoadingState, getInitialLoadingState,
); );
const [historyState, setHistoryState] = useState<HistoryState>(
getInitialHistoryState,
);
const [proAccess, setProAccess] = useState<ProAccessState>( const [proAccess, setProAccess] = useState<ProAccessState>(
getInitialProAccessState, 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 = () => { const closeFutureModal = () => {
modalOpenSeqRef.current += 1; modalOpenSeqRef.current += 1;
setFutureModalDate(null); setFutureModalDate(null);
setForecastModalMode(null); setForecastModalMode(null);
}; };
const closeHistory = () =>
setHistoryState((current) => ({ ...current, isOpen: false }));
const openFutureModal = async (dateStr: string, forceRefresh = false) => { const openFutureModal = async (dateStr: string, forceRefresh = false) => {
mapStopMotionRef.current(); mapStopMotionRef.current();
@@ -1679,7 +1522,6 @@ export function DashboardStoreProvider({
citySummariesByName, citySummariesByName,
clearCityFocus, clearCityFocus,
closeFutureModal, closeFutureModal,
closeHistory,
closePanel: () => { closePanel: () => {
setIsPanelOpen(false); setIsPanelOpen(false);
}, },
@@ -1688,14 +1530,12 @@ export function DashboardStoreProvider({
focusCity, focusCity,
forecastModalMode, forecastModalMode,
futureModalDate, futureModalDate,
historyState,
isPanelOpen, isPanelOpen,
loadCities, loadCities,
preloadCityFromRow, preloadCityFromRow,
loadingState, loadingState,
proAccess, proAccess,
openFutureModal, openFutureModal,
openHistory,
openTodayModal, openTodayModal,
registerMapStopMotion: (stopMotion: () => void) => { registerMapStopMotion: (stopMotion: () => void) => {
mapStopMotionRef.current = stopMotion; mapStopMotionRef.current = stopMotion;
@@ -1716,7 +1556,6 @@ export function DashboardStoreProvider({
citySummariesByName, citySummariesByName,
forecastModalMode, forecastModalMode,
futureModalDate, futureModalDate,
historyState,
isPanelOpen, isPanelOpen,
loadingState, loadingState,
proAccess, proAccess,
@@ -1784,14 +1623,6 @@ export function DashboardStoreProvider({
setForecastDate, setForecastDate,
], ],
); );
const dashboardHistoryValue = useMemo<DashboardHistoryContextValue>(
() => ({
closeHistory,
historyState,
openHistory,
}),
[closeHistory, historyState, openHistory],
);
const dashboardProAccessValue = useMemo<DashboardProAccessContextValue>( const dashboardProAccessValue = useMemo<DashboardProAccessContextValue>(
() => ({ () => ({
proAccess, proAccess,
@@ -1805,13 +1636,11 @@ export function DashboardStoreProvider({
<DashboardActionsContext.Provider value={dashboardActionsValue}> <DashboardActionsContext.Provider value={dashboardActionsValue}>
<DashboardProAccessContext.Provider value={dashboardProAccessValue}> <DashboardProAccessContext.Provider value={dashboardProAccessValue}>
<DashboardModalContext.Provider value={dashboardModalValue}> <DashboardModalContext.Provider value={dashboardModalValue}>
<DashboardHistoryContext.Provider value={dashboardHistoryValue}> <DashboardSelectionContext.Provider value={dashboardSelectionValue}>
<DashboardSelectionContext.Provider value={dashboardSelectionValue}> <CityDetailsContext.Provider value={cityDetailsValue}>
<CityDetailsContext.Provider value={cityDetailsValue}> {children}
{children} </CityDetailsContext.Provider>
</CityDetailsContext.Provider> </DashboardSelectionContext.Provider>
</DashboardSelectionContext.Provider>
</DashboardHistoryContext.Provider>
</DashboardModalContext.Provider> </DashboardModalContext.Provider>
</DashboardProAccessContext.Provider> </DashboardProAccessContext.Provider>
</DashboardActionsContext.Provider> </DashboardActionsContext.Provider>
@@ -1859,16 +1688,6 @@ export function useDashboardModal() {
return context; 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() { export function useProAccess() {
const context = useContext(DashboardProAccessContext); const context = useContext(DashboardProAccessContext);
if (!context) { if (!context) {
@@ -1899,20 +1718,3 @@ export function useCityData(name?: string | null) {
selection.selectedCity === key, 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,
};
}
-39
View File
@@ -4,7 +4,6 @@ import {
CityDetail, CityDetail,
CityListItem, CityListItem,
CitySummary, CitySummary,
HistoryPayload,
MarketScan, MarketScan,
ScanTerminalFilters, ScanTerminalFilters,
ScanTerminalResponse, ScanTerminalResponse,
@@ -20,7 +19,6 @@ const CACHE_TTL_MS = 30 * 60 * 1000;
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000; const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000; const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000;
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>(); const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>(); const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
const pendingCityMarketScanRequests = new Map< const pendingCityMarketScanRequests = new Map<
string, string,
@@ -452,43 +450,6 @@ export const dashboardClient = {
return request; 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<HistoryPayload>(
`/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) { isCityDetailFresh(meta?: CityCacheMeta | null) {
return isFresh(meta); return isFresh(meta);
+2 -68
View File
@@ -976,78 +976,13 @@ export interface AmosData {
observation_time_local?: string | null; observation_time_local?: string | null;
} }
export interface HistoryPoint {
date: string;
actual: number | null;
deb: number | null;
mu?: number | null;
mgm?: number | null;
forecasts?: Record<string, number | null>;
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 { export interface LoadingState {
cities: boolean; cities: boolean;
cityDetail: boolean; cityDetail: boolean;
refresh: boolean; refresh: boolean; marketScan?: boolean;
history: boolean; futureDeep?: boolean;}
marketScan?: boolean;
futureDeep?: boolean;
historyRecords?: boolean;
}
export interface HistoryState {
isOpen: boolean;
loading: boolean;
recordsLoading: boolean;
error: string | null;
dataByCity: Record<string, HistoryPoint[]>;
metaByCity: Record<string, HistoryPayloadMeta>;
}
export interface ProAccessState { export interface ProAccessState {
loading: boolean; loading: boolean;
@@ -1073,6 +1008,5 @@ export interface DashboardState {
selectedForecastDate: string | null; selectedForecastDate: string | null;
forecastModalMode: ForecastModalMode | null; forecastModalMode: ForecastModalMode | null;
loadingState: LoadingState; loadingState: LoadingState;
historyState: HistoryState;
proAccess: ProAccessState; proAccess: ProAccessState;
} }
-137
View File
@@ -2,7 +2,6 @@ import { Locale } from "@/lib/i18n";
import { import {
AiAnalysisStructured, AiAnalysisStructured,
CityDetail, CityDetail,
HistoryPoint,
NearbyStation, NearbyStation,
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
import { import {
@@ -1715,142 +1714,6 @@ export function getShortTermNowcastLines(
return rows; 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<string, number[]> = {};
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 { function toFiniteNumber(value: unknown): number | null {
const numeric = Number(value); const numeric = Number(value);
-34
View File
@@ -44,7 +44,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"detail.closeAria": "关闭城市详情面板", "detail.closeAria": "关闭城市详情面板",
"detail.waitSelect": "等待选择城市", "detail.waitSelect": "等待选择城市",
"detail.todayAnalysis": "今日日内分析", "detail.todayAnalysis": "今日日内分析",
"detail.history": "历史对账",
"detail.loading": "正在加载城市详情...", "detail.loading": "正在加载城市详情...",
"detail.emptyHint": "从左侧城市列表选择一个城市查看详情。", "detail.emptyHint": "从左侧城市列表选择一个城市查看详情。",
"detail.sceneryAlt": "{city} 风景照", "detail.sceneryAlt": "{city} 风景照",
@@ -72,22 +71,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.footer": "guide.footer":
"数据源以 METAR、香港天文台(HKO)、NOAA 指定站点、Turkish MGM、Open-Meteo、weather.gov 为主。", "数据源以 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.todayTitle": "{city} · 今日日内分析",
"future.dateTitle": "{city} · {date} 未来日期分析", "future.dateTitle": "{city} · {date} 未来日期分析",
@@ -242,7 +225,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"detail.closeAria": "Close city detail panel", "detail.closeAria": "Close city detail panel",
"detail.waitSelect": "Waiting for city selection", "detail.waitSelect": "Waiting for city selection",
"detail.todayAnalysis": "Today's Intraday", "detail.todayAnalysis": "Today's Intraday",
"detail.history": "History Reconciliation",
"detail.loading": "Loading city details...", "detail.loading": "Loading city details...",
"detail.emptyHint": "Select a city from the left list to view details.", "detail.emptyHint": "Select a city from the left list to view details.",
"detail.sceneryAlt": "{city} scenery", "detail.sceneryAlt": "{city} scenery",
@@ -271,22 +253,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"guide.footer": "guide.footer":
"Primary data sources are METAR, Hong Kong Observatory (HKO), designated NOAA stations, Turkish MGM, Open-Meteo, and weather.gov.", "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.todayTitle": "{city} · Intraday Analysis",
"future.dateTitle": "{city} · {date} Future-date Analysis", "future.dateTitle": "{city} · {date} Future-date Analysis",
-1
View File
@@ -190,7 +190,6 @@ export const config = {
"/api/ops/:path*", "/api/ops/:path*",
"/api/payments/:path*", "/api/payments/:path*",
"/api/system/:path*", "/api/system/:path*",
"/api/history/:path*",
"/api/city/:path*/detail:path*", "/api/city/:path*/detail:path*",
"/api/scan/terminal/ai:path*", "/api/scan/terminal/ai:path*",
], ],
+140
View File
@@ -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()
+1 -2
View File
@@ -61,8 +61,7 @@ def _sf(value):
def _is_excluded_model_name(model_name: str) -> bool: def _is_excluded_model_name(model_name: str) -> bool:
normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "") return False
return "meteoblue" in normalized
def _normalize_deb_model_name(model_name: str) -> str: def _normalize_deb_model_name(model_name: str) -> str:
+3 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import csv import csv
import math import math
import os
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -22,8 +23,8 @@ _official_intraday_repo = OfficialIntradayObservationRepository()
class SettlementSourceMixin: class SettlementSourceMixin:
IMGW_METEO_API_BASE = "https://meteo.imgw.pl/api/v1" IMGW_METEO_API_BASE = "https://meteo.imgw.pl/api/v1"
IMGW_METEO_API_TOKEN = "p4DXKjsYadfBV21TYrDk" IMGW_METEO_API_TOKEN = os.environ.get("IMGW_METEO_API_TOKEN", "")
NOAA_WRH_MESO_TOKEN = "7c76618b66c74aee913bdbae4b448bdd" NOAA_WRH_MESO_TOKEN = os.environ.get("NOAA_WRH_MESO_TOKEN", "")
NOAA_WRH_TIMESERIES_REFERER_BASE = "https://www.weather.gov/wrh/timeseries?site=" NOAA_WRH_TIMESERIES_REFERER_BASE = "https://www.weather.gov/wrh/timeseries?site="
def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]: def _get_settlement_cache(self, key: str) -> Optional[Dict[str, Any]]:
-12
View File
@@ -307,16 +307,6 @@ class DBManager:
source_fingerprint TEXT 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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS cache_refresh_locks ( CREATE TABLE IF NOT EXISTS cache_refresh_locks (
cache_key TEXT PRIMARY KEY, cache_key TEXT PRIMARY KEY,
@@ -430,8 +420,6 @@ class DBManager:
return "city_nearby_cache" return "city_nearby_cache"
if normalized == "market": if normalized == "market":
return "city_market_cache" return "city_market_cache"
if normalized == "history_preview":
return "city_history_preview_cache"
return None return None
def get_city_cache(self, kind: str, city: str) -> Optional[Dict[str, Any]]: def get_city_cache(self, kind: str, city: str) -> Optional[Dict[str, Any]]:
+1 -47
View File
@@ -6,7 +6,7 @@ import web.routes as routes
import web.scan_terminal_cache as scan_terminal_cache import web.scan_terminal_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service import web.scan_terminal_service as scan_terminal_service
from web.scan_terminal_cache import scan_terminal_cache_key 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) client = TestClient(app)
@@ -806,49 +806,3 @@ def test_scan_terminal_cache_key_includes_filter_dimensions():
assert first != second assert first != second
assert "trend" in second assert "trend" in second
assert "week" 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"
+2 -30
View File
@@ -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.city_time import get_city_utc_offset_seconds
from src.data_collection.nmc_sources import NMC_CITY_REFERENCES from src.data_collection.nmc_sources import NMC_CITY_REFERENCES
from src.database.runtime_state import IntradayPathSnapshotRepository 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 ( from web.services.city_payloads import (
build_city_detail_payload as _city_payload_detail, build_city_detail_payload as _city_payload_detail,
build_city_market_scan_payload as _city_payload_market_scan, 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)} _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( def _maybe_enrich_dynamic_commentary_with_groq(
city: str, _city: str,
result: Dict[str, Any], result: Dict[str, Any],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
return _groq_enrich(city, result) return result.get("dynamic_commentary") or {"summary": "", "notes": []}
+1 -3
View File
@@ -405,8 +405,7 @@ def _sf(v) -> Optional[float]:
def _is_excluded_model_name(model_name: str) -> bool: def _is_excluded_model_name(model_name: str) -> bool:
normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "") return False
return "meteoblue" in normalized
def _sqlite_health() -> Dict[str, Any]: 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 {} weather_cfg = _config.get("weather", {}) if isinstance(_config, dict) else {}
return { return {
"supabase_configured": bool(SUPABASE_ENTITLEMENT.configured), "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")), "telegram_bot_configured": bool((_config.get("telegram", {}) or {}).get("bot_token")),
"walletconnect_configured": bool(os.getenv("NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID")), "walletconnect_configured": bool(os.getenv("NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID")),
"weather_sources": { "weather_sources": {
-15
View File
@@ -7,7 +7,6 @@ from fastapi import APIRouter, BackgroundTasks, Request
from web.services.city_api import ( from web.services.city_api import (
get_city_detail_aggregate_payload, get_city_detail_aggregate_payload,
get_city_detail_payload, get_city_detail_payload,
get_city_history_payload,
get_city_market_scan_payload, get_city_market_scan_payload,
get_city_summary_payload, get_city_summary_payload,
list_cities_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") @router.get("/api/city/{name}/summary")
async def city_summary( async def city_summary(
request: Request, request: Request,
-18
View File
@@ -109,24 +109,6 @@ async def get_city_detail_payload(
return await run_in_threadpool(legacy_routes._analyze, city, force_refresh, False, detail_mode) 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( async def get_city_summary_payload(
_request: Request, _request: Request,
name: str, name: str,
+4 -290
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import os import os
import time import time
from datetime import datetime, timedelta from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException from fastapi import APIRouter, BackgroundTasks, HTTPException
@@ -13,8 +13,7 @@ from src.database.db_manager import DBManager
from src.database.runtime_state import ( from src.database.runtime_state import (
DailyRecordRepository, DailyRecordRepository,
STATE_STORAGE_SQLITE, STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository, TruthRecordRepository, # noqa: F401 - compatibility export for ops/truth-history
TruthRecordRepository,
get_state_storage_mode, get_state_storage_mode,
) )
from src.analysis.settlement_rounding import apply_city_settlement 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 CITY_RISK_PROFILES, # noqa: F401 - compatibility export for tests and transitional routers
PAYMENT_CHECKOUT, # 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 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 SUPABASE_ENTITLEMENT, # noqa: F401 - compatibility export for tests and transitional routers
ConfirmPaymentTxRequest, # 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 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_auth_points, # noqa: F401 - compatibility export for tests and transitional routers
_resolve_weekly_profile, # noqa: F401 - compatibility export for tests and transitional routers _resolve_weekly_profile, # noqa: F401 - compatibility export for tests and transitional routers
_sf, _sf,
_is_excluded_model_name,
) )
router = APIRouter() router = APIRouter()
@@ -67,8 +65,6 @@ _CACHE_DB = DBManager()
_DEB_RECENT_LOOKBACK = 7 _DEB_RECENT_LOOKBACK = 7
_DEB_RECENT_MIN_SAMPLES = 3 _DEB_RECENT_MIN_SAMPLES = 3
_daily_record_repo = DailyRecordRepository() _daily_record_repo = DailyRecordRepository()
_truth_record_repo = TruthRecordRepository()
_training_feature_repo = TrainingFeatureRecordRepository()
TRACKABLE_ANALYTICS_EVENTS = { TRACKABLE_ANALYTICS_EVENTS = {
"signup_completed", "signup_completed",
@@ -100,7 +96,6 @@ DEFAULT_STATUS_CITIES = [
"paris", "paris",
"madrid", "madrid",
] ]
HISTORY_PREVIEW_DAY_LIMIT = 21
ASIA_CORE_CITIES = [ ASIA_CORE_CITIES = [
"hong kong", "hong kong",
"taipei", "taipei",
@@ -151,10 +146,6 @@ MARKET_SCAN_PAYLOAD_TTL_SEC = max(
5, 5,
int(os.getenv("POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC", "30")), 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"))) 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 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( def _schedule_cache_refresh(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
@@ -507,7 +329,7 @@ def _schedule_cache_refresh(
) -> bool: ) -> bool:
normalized_kind = str(kind or "").strip().lower() normalized_kind = str(kind or "").strip().lower()
normalized_city = str(city 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 return False
cache_key = f"city:{normalized_kind}:{normalized_city}" cache_key = f"city:{normalized_kind}:{normalized_city}"
owner = _CACHE_DB.acquire_cache_refresh_lock( 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) _refresh_city_panel_cache(normalized_city, force_refresh=force_refresh)
elif normalized_kind == "nearby": elif normalized_kind == "nearby":
_refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh) _refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh)
elif normalized_kind == "history_preview":
_refresh_city_history_preview_cache(normalized_city)
else: else:
_refresh_city_market_cache(normalized_city, force_refresh=force_refresh) _refresh_city_market_cache(normalized_city, force_refresh=force_refresh)
except Exception as exc: except Exception as exc:
@@ -544,108 +364,6 @@ def _schedule_cache_refresh(
return True 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: def _normalize_city_or_404(name: str) -> str:
city = name.lower().strip().replace("-", " ") city = name.lower().strip().replace("-", " ")
@@ -799,9 +517,5 @@ def _build_recent_deb_performance_index(
} }
return 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('__'))] __all__ = [name for name in globals() if not (name.startswith('__') and name.endswith('__'))]
-223
View File
@@ -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
+3 -1
View File
@@ -437,7 +437,9 @@ def get_ops_logs(
def get_ops_health_check(request: Request) -> dict[str, Any]: def get_ops_health_check(request: Request) -> dict[str, Any]:
_require_ops(request) _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] = {} results: dict[str, dict] = {}
timeout = 3 timeout = 3
+1 -3
View File
@@ -35,9 +35,7 @@ def get_system_cache_status(request: Request, cities: Optional[str] = None) -> D
"summary": legacy_routes.CITY_SUMMARY_CACHE_TTL_SEC, "summary": legacy_routes.CITY_SUMMARY_CACHE_TTL_SEC,
"panel": legacy_routes.CITY_PANEL_CACHE_TTL_SEC, "panel": legacy_routes.CITY_PANEL_CACHE_TTL_SEC,
"nearby": legacy_routes.CITY_NEARBY_CACHE_TTL_SEC, "nearby": legacy_routes.CITY_NEARBY_CACHE_TTL_SEC,
"market": legacy_routes.CITY_MARKET_CACHE_TTL_SEC, "market": legacy_routes.CITY_MARKET_CACHE_TTL_SEC, }
"history_preview": legacy_routes.CITY_HISTORY_PREVIEW_CACHE_TTL_SEC,
}
items = [] items = []
for city in selected: for city in selected:
row = {"city": city} row = {"city": city}