移除未使用的 Groq 和 Meteoblue 服务代码及配置
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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]`
|
||||
|
||||
鉴权:
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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*",
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('__'))]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user