移除未使用的 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
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
+42
View File
@@ -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=<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 支持地图、城市详情、今日日内分析、历史准确率对账和账户中心
- `/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]`
鉴权:
-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,
selectedDetail: store.selectedDetail,
suspendMotion:
Boolean(store.futureModalDate) ||
store.historyState.isOpen,
suspendMotion: Boolean(store.futureModalDate),
isLoadingDetail: store.loadingState.cityDetail,
});
@@ -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);
+1 -6
View File
@@ -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;
+6 -82
View File
@@ -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.",
},
],
},
+5 -203
View File
@@ -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<void>;
preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void;
openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise<void>;
openHistory: () => Promise<void>;
openTodayModal: (forceRefresh?: boolean) => Promise<void>;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
@@ -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<Pick<
> | null>(null);
const DashboardModalContext =
createContext<DashboardModalContextValue | null>(null);
const DashboardHistoryContext =
createContext<DashboardHistoryContextValue | null>(null);
const DashboardProAccessContext =
createContext<DashboardProAccessContextValue | null>(null);
const DashboardSelectionContext = createContext<Pick<
@@ -126,24 +114,11 @@ function getInitialLoadingState(): LoadingState {
cities: false,
cityDetail: false,
futureDeep: false,
history: false,
historyRecords: false,
refresh: false,
marketScan: false,
};
}
function getInitialHistoryState(): HistoryState {
return {
dataByCity: {},
error: null,
isOpen: false,
loading: false,
metaByCity: {},
recordsLoading: false,
};
}
function getInitialProAccessState(): ProAccessState {
if (isBrowserLocalFullAccess()) {
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({
children,
}: {
@@ -710,9 +671,6 @@ export function DashboardStoreProvider({
const [loadingState, setLoadingState] = useState<LoadingState>(
getInitialLoadingState,
);
const [historyState, setHistoryState] = useState<HistoryState>(
getInitialHistoryState,
);
const [proAccess, setProAccess] = useState<ProAccessState>(
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<DashboardHistoryContextValue>(
() => ({
closeHistory,
historyState,
openHistory,
}),
[closeHistory, historyState, openHistory],
);
const dashboardProAccessValue = useMemo<DashboardProAccessContextValue>(
() => ({
proAccess,
@@ -1805,13 +1636,11 @@ export function DashboardStoreProvider({
<DashboardActionsContext.Provider value={dashboardActionsValue}>
<DashboardProAccessContext.Provider value={dashboardProAccessValue}>
<DashboardModalContext.Provider value={dashboardModalValue}>
<DashboardHistoryContext.Provider value={dashboardHistoryValue}>
<DashboardSelectionContext.Provider value={dashboardSelectionValue}>
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
</DashboardSelectionContext.Provider>
</DashboardHistoryContext.Provider>
<DashboardSelectionContext.Provider value={dashboardSelectionValue}>
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
</DashboardSelectionContext.Provider>
</DashboardModalContext.Provider>
</DashboardProAccessContext.Provider>
</DashboardActionsContext.Provider>
@@ -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,
};
}
-39
View File
@@ -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<string, Promise<CityDetail>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
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<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) {
return isFresh(meta);
+2 -68
View File
@@ -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<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 {
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<string, HistoryPoint[]>;
metaByCity: Record<string, HistoryPayloadMeta>;
}
export interface ProAccessState {
loading: boolean;
@@ -1073,6 +1008,5 @@ export interface DashboardState {
selectedForecastDate: string | null;
forecastModalMode: ForecastModalMode | null;
loadingState: LoadingState;
historyState: HistoryState;
proAccess: ProAccessState;
}
-137
View File
@@ -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<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 {
const numeric = Number(value);
-34
View File
@@ -44,7 +44,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"detail.closeAria": "关闭城市详情面板",
"detail.waitSelect": "等待选择城市",
"detail.todayAnalysis": "今日日内分析",
"detail.history": "历史对账",
"detail.loading": "正在加载城市详情...",
"detail.emptyHint": "从左侧城市列表选择一个城市查看详情。",
"detail.sceneryAlt": "{city} 风景照",
@@ -72,22 +71,6 @@ const MESSAGES: Record<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"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<Locale, Record<string, string>> = {
"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",
-1
View File
@@ -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*",
],
+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:
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:
+3 -2
View File
@@ -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]]:
-12
View File
@@ -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]]:
+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_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"
+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.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": []}
+1 -3
View File
@@ -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": {
-15
View File
@@ -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,
-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)
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,
+4 -290
View File
@@ -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('__'))]
-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]:
_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
+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,
"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}