Compare commits

...

9 Commits

Author SHA1 Message Date
2569718930@qq.com 1a5bab9fad Fix DEB training truth finality handling 2026-06-24 18:18:45 +08:00
2569718930@qq.com 0fef948fac Localize public brief pages 2026-06-24 18:14:35 +08:00
2569718930@qq.com 07c5451f1d Allow manual payment tx submission during transient validation 2026-06-24 17:03:45 +08:00
2569718930@qq.com 51b0e41c0f Track public content acquisition events 2026-06-24 16:45:47 +08:00
2569718930@qq.com 0a46933f6a Add public market brief content assets 2026-06-24 16:41:13 +08:00
2569718930@qq.com 004439b736 Add Microsoft Clarity tracking 2026-06-24 16:25:49 +08:00
2569718930@qq.com b8a1950932 Fix Ankara chart stale MGM observations 2026-06-23 19:02:31 +08:00
2569718930@qq.com 221edb480c Bootstrap missing training actual history 2026-06-23 18:27:32 +08:00
2569718930@qq.com ba2db42eaa Restore DEB training settlement worker 2026-06-23 18:16:46 +08:00
41 changed files with 3277 additions and 40 deletions
+3
View File
@@ -350,6 +350,9 @@ compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating cache warmer..."
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
echo "Updating training settlement worker..."
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
echo "Updating frontend..."
compose_up_retry "frontend" -d --no-deps polyweather_frontend
+43
View File
@@ -253,6 +253,49 @@ services:
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
polyweather_training_settlement:
command: python -m web.training_settlement_worker
container_name: polyweather_training_settlement
depends_on:
polyweather_redis:
condition: service_healthy
polyweather_web:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
cpus: ${POLYWEATHER_TRAINING_SETTLEMENT_CPUS:-0.50}
env_file: *id001
environment:
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
POLYWEATHER_SERVICE_ROLE: training_settlement
POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC:-60}
POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}
POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS: ${POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS:-10}
healthcheck:
interval: 60s
retries: 3
test:
- CMD
- python
- -c
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
c.execute('SELECT 1'); c.close()
timeout: 10s
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
mem_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEM_LIMIT:-768m}
memswap_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEMSWAP_LIMIT:-1g}
pids_limit: 256
restart: unless-stopped
user: ${UID:-1000}:${GID:-1000}
volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
x-polyweather-base:
env_file: *id001
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
@@ -0,0 +1,138 @@
# DEB Training Settlement Worker Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore automatic DEB training data freshness after the realtime collector/canonical-cache split.
**Architecture:** Add a dedicated low-frequency training settlement service that runs city analysis for forecast/DEB snapshots and reconciles recent settled actual highs. Keep it separate from the high-frequency observation collector so user-facing realtime updates stay light.
**Tech Stack:** Python, FastAPI service modules, Docker Compose, pytest, existing `update_daily_record()` and `reconcile_recent_actual_highs()` paths.
---
### Task 1: Training Settlement Service
**Files:**
- Create: `web/training_settlement_service.py`
- Create: `tests/test_training_settlement_service.py`
- [ ] **Step 1: Write the failing service test**
```python
def test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities():
calls = {"analysis": [], "reconcile": []}
def analysis_runner(city):
calls["analysis"].append(city)
return {"city": city, "deb": {"prediction": 31.2}}
def reconciler(city, *, lookback_days):
calls["reconcile"].append((city, lookback_days))
return {"ok": True, "updated": 1}
result = run_training_settlement_cycle(
city_registry={
"shanghai": {"icao": "ZSSS", "settlement_source": "metar"},
"legacy": {"settlement_source": "wunderground"},
},
analysis_runner=analysis_runner,
actual_reconciler=reconciler,
lookback_days=9,
)
assert result["ok"] is True
assert result["processed"] == 1
assert calls["analysis"] == ["shanghai"]
assert calls["reconcile"] == [("shanghai", 9)]
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `python -m pytest tests/test_training_settlement_service.py::test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities -q`
Expected: FAIL because `web.training_settlement_service` does not exist.
- [ ] **Step 3: Implement the service**
Create `run_training_settlement_cycle()` with injectable `city_registry`, `analysis_runner`, and `actual_reconciler`. Default analysis runner calls `web.analysis_service._analyze(city, force_refresh=False, detail_mode="panel")`; default reconciler calls `src.analysis.deb_algorithm.reconcile_recent_actual_highs()`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `python -m pytest tests/test_training_settlement_service.py -q`
Expected: PASS.
### Task 2: Worker Entrypoint And Compose
**Files:**
- Create: `web/training_settlement_worker.py`
- Modify: `docker-compose.yml`
- Modify: `tests/test_deployment_runtime_config.py`
- [ ] **Step 1: Write failing deployment tests**
Assert the compose file contains `polyweather_training_settlement`, command `python -m web.training_settlement_worker`, role `training_settlement`, and conservative interval/lookback environment variables.
- [ ] **Step 2: Run the deployment test to verify it fails**
Run: `python -m pytest tests/test_deployment_runtime_config.py::test_runtime_compose_splits_realtime_workers -q`
Expected: FAIL because the worker service is absent.
- [ ] **Step 3: Implement worker and compose service**
The worker loops `run_training_settlement_cycle()` every `POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC` seconds, with initial delay and lookback from environment.
- [ ] **Step 4: Run focused tests**
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py -q`
Expected: PASS.
### Task 3: Stale Monitoring
**Files:**
- Modify: `web/diagnostics/health.py`
- Modify: `web/services/system_api.py`
- Modify: `tests/test_web_observability.py`
- [ ] **Step 1: Write failing observability tests**
Assert system status training summaries include `stale_days` for daily/truth/features, and Prometheus exports stale gauges for training data.
- [ ] **Step 2: Run focused observability tests to verify failure**
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
Expected: FAIL because stale fields/gauges are absent.
- [ ] **Step 3: Implement stale summary and gauges**
Add date-diff calculation against UTC today. Export `polyweather_daily_records_stale_days`, `polyweather_truth_records_stale_days`, `polyweather_training_features_stale_days`, and `polyweather_training_data_stale`.
- [ ] **Step 4: Run focused observability tests**
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
Expected: PASS.
### Task 4: Verification And Backfill
**Files:**
- Optional create: `scripts/backfill_training_settlement.py`
- [ ] **Step 1: Run backend checks**
Run: `python -m ruff check .`
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py tests/test_web_observability.py -q`
- [ ] **Step 2: Run local one-shot cycle**
Run: `python -m web.training_settlement_worker --once --lookback-days 10 --cities shanghai`
Expected: JSON-like log output with `ok=True`; local DB should get a fresh row for the current local target date if analysis succeeds.
- [ ] **Step 3: Production note**
Missed forecast snapshots from 2026-06-15 to 2026-06-22 cannot be reconstructed honestly unless archived city analysis payloads exist. The worker restores forward automatic samples; actual-high truth can still be reconciled for supported settlement sources.
+162
View File
@@ -0,0 +1,162 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { cookies, headers } from "next/headers";
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import {
PUBLIC_BRIEFS,
absolutePublicUrl,
briefPath,
getBrief,
localizeBrief,
} from "@/content/public-content";
type BriefPageParams = {
city: string;
date: string;
};
type BriefSearchParams = Promise<Record<string, string | string[] | undefined>>;
async function resolvePublicContentLocale(searchParams: BriefSearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export function generateStaticParams() {
return PUBLIC_BRIEFS.map((brief) => ({
city: brief.city,
date: brief.date,
}));
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<BriefPageParams>;
searchParams: BriefSearchParams;
}): Promise<Metadata> {
const [{ city, date }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const brief = getBrief(city, date);
if (!brief) {
return {
title: "Brief not found",
};
}
const localizedBrief = localizeBrief(brief, locale);
const pathname = briefPath(localizedBrief);
return {
title: localizedBrief.title,
description: localizedBrief.description,
alternates: {
canonical: pathname,
},
openGraph: {
type: "article",
title: localizedBrief.title,
description: localizedBrief.description,
url: pathname,
publishedTime: localizedBrief.publishedAt,
modifiedTime: localizedBrief.updatedAt,
},
twitter: {
card: "summary",
title: localizedBrief.title,
description: localizedBrief.description,
},
};
}
export default async function BriefDetailPage({
params,
searchParams,
}: {
params: Promise<BriefPageParams>;
searchParams: BriefSearchParams;
}) {
const [{ city, date }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const brief = getBrief(city, date);
if (!brief) {
notFound();
}
const localizedBrief = localizeBrief(brief, locale);
const pathname = briefPath(localizedBrief);
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "Article",
headline: localizedBrief.title,
description: localizedBrief.description,
datePublished: localizedBrief.publishedAt,
dateModified: localizedBrief.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
publisher: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
about: [
localizedBrief.cityName,
localizedBrief.market,
localizedBrief.settlementSource,
"DEB forecast methodology",
],
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Briefs",
item: absolutePublicUrl("/briefs"),
},
{
"@type": "ListItem",
position: 2,
name: localizedBrief.cityName,
item: absolutePublicUrl(pathname),
},
],
},
];
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<BriefDetailPageView brief={brief} locale={locale} />
</>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
type BriefsSearchParams = Promise<Record<string, string | string[] | undefined>>;
async function resolvePublicContentLocale(searchParams: BriefsSearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export async function generateMetadata({
searchParams,
}: {
searchParams: BriefsSearchParams;
}): Promise<Metadata> {
const locale = await resolvePublicContentLocale(searchParams);
const copy = PUBLIC_CONTENT_COPY[locale];
return {
title: copy.briefIndexEyebrow,
description: copy.briefIndexDescription,
alternates: {
canonical: "/briefs",
},
openGraph: {
title: `Weather Market Brief | PolyWeather`,
description: copy.briefIndexDescription,
url: "/briefs",
},
};
}
export default async function BriefsPage({
searchParams,
}: {
searchParams: BriefsSearchParams;
}) {
const locale = await resolvePublicContentLocale(searchParams);
return <BriefsIndexPageView locale={locale} />;
}
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { RegisterSW } from "@/components/dashboard/RegisterSW";
import { MicrosoftClarity } from "@/components/observability/MicrosoftClarity";
import "./globals.css";
const inter = Inter({
@@ -89,6 +90,7 @@ export default function RootLayout({
</a>
<main id="main-content">{children}</main>
<RegisterSW />
<MicrosoftClarity />
</body>
</html>
);
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages";
import {
METHODOLOGY_PAGES,
absolutePublicUrl,
getMethodologyPage,
methodologyPath,
} from "@/content/public-content";
type MethodologyPageParams = {
slug: string;
};
export function generateStaticParams() {
return METHODOLOGY_PAGES.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<MethodologyPageParams>;
}): Promise<Metadata> {
const { slug } = await params;
const page = getMethodologyPage(slug);
if (!page) {
return {
title: "Methodology not found",
};
}
return {
title: page.title,
description: page.description,
alternates: {
canonical: methodologyPath(page),
},
openGraph: {
type: "article",
title: page.title,
description: page.description,
url: methodologyPath(page),
modifiedTime: page.updatedAt,
},
};
}
export default async function MethodologyDetailPage({
params,
}: {
params: Promise<MethodologyPageParams>;
}) {
const { slug } = await params;
const page = getMethodologyPage(slug);
if (!page) {
notFound();
}
const pathname = methodologyPath(page);
const jsonLd = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: page.title,
description: page.description,
dateModified: page.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<MethodologyDetailPageView page={page} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { MethodologyIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Methodology",
description:
"PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
alternates: {
canonical: "/methodology",
},
openGraph: {
title: "Methodology | PolyWeather",
description:
"Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
url: "/methodology",
},
};
export default function MethodologyPage() {
return <MethodologyIndexPageView />;
}
+44 -2
View File
@@ -1,20 +1,62 @@
import type { MetadataRoute } from "next";
import {
METHODOLOGY_PAGES,
PUBLIC_BRIEFS,
SOURCE_PAGES,
} from "@/content/public-content";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://polyweather.top";
const now = new Date();
return [
{
url: baseUrl,
lastModified: new Date(),
lastModified: now,
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${baseUrl}/auth/login`,
lastModified: new Date(),
lastModified: now,
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${baseUrl}/briefs`,
lastModified: now,
changeFrequency: "weekly",
priority: 0.9,
},
{
url: `${baseUrl}/methodology`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/sources`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
...PUBLIC_BRIEFS.map((brief) => ({
url: `${baseUrl}/briefs/${brief.city}/${brief.date}`,
lastModified: new Date(brief.updatedAt),
changeFrequency: "daily" as const,
priority: 0.85,
})),
...METHODOLOGY_PAGES.map((page) => ({
url: `${baseUrl}/methodology/${page.slug}`,
lastModified: new Date(page.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.75,
})),
...SOURCE_PAGES.map((source) => ({
url: `${baseUrl}/sources/${source.slug}`,
lastModified: new Date(source.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.7,
})),
];
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { SourceDetailPageView } from "@/components/public-content/PublicContentPages";
import {
SOURCE_PAGES,
absolutePublicUrl,
getSourcePage,
sourcePath,
} from "@/content/public-content";
type SourcePageParams = {
slug: string;
};
export function generateStaticParams() {
return SOURCE_PAGES.map((source) => ({ slug: source.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<SourcePageParams>;
}): Promise<Metadata> {
const { slug } = await params;
const source = getSourcePage(slug);
if (!source) {
return {
title: "Source not found",
};
}
return {
title: source.title,
description: source.description,
alternates: {
canonical: sourcePath(source),
},
openGraph: {
type: "article",
title: source.title,
description: source.description,
url: sourcePath(source),
modifiedTime: source.updatedAt,
},
};
}
export default async function SourceDetailPage({
params,
}: {
params: Promise<SourcePageParams>;
}) {
const { slug } = await params;
const source = getSourcePage(slug);
if (!source) {
notFound();
}
const pathname = sourcePath(source);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Dataset",
name: source.title,
description: source.description,
url: absolutePublicUrl(pathname),
dateModified: source.updatedAt,
creator: {
"@type": "Organization",
name: source.operator,
},
includedInDataCatalog: {
"@type": "DataCatalog",
name: "PolyWeather source notes",
url: absolutePublicUrl("/sources"),
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<SourceDetailPageView source={source} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { SourcesIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Weather Sources",
description:
"PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
alternates: {
canonical: "/sources",
},
openGraph: {
title: "Weather Sources | PolyWeather",
description:
"Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
url: "/sources",
},
};
export default function SourcesPage() {
return <SourcesIndexPageView />;
}
@@ -94,6 +94,12 @@ export function runTests() {
paymentFlowSource.includes("/validate"),
"manual payment flow must validate tx hashes with the backend before submission",
);
assert(
paymentFlowSource.includes("confirmRes.status === 503") &&
paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') &&
paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'),
"manual payment confirm must treat transient payment RPC failures as pending after tx hash submission",
);
assert(
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
@@ -826,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||
(confirmRes.status === 503 &&
(lowerRaw.includes("cannot connect payment rpc") ||
lowerRaw.includes("payment rpc chain mismatch"))) ||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
@@ -803,6 +803,87 @@ export function runTests() {
"Ankara scan-row-seeded airport-primary curve should default to MGM instead of NOAA MADIS when source metadata is missing",
);
const staleAnkaraDetail = toFullChartDetail({
localDate: "2026-06-23",
localTime: "13:51",
times: ["12:00", "13:00", "14:00", "15:00"],
temps: [22, 24, 25, 24],
airportPrimary: {
temp: 27.1,
obs_time: "2026-06-23T10:39:00Z",
source_code: "mgm",
source_label: "MGM",
},
airportPrimaryTodayObs: [["2026-06-23T10:39:00Z", 27.1]],
metarTodayObs: [["2026-06-23T10:20:00Z", 24.0]],
debHourlyPath: {
times: ["12:00", "13:00", "14:00", "15:00"],
temps: [20.5, 21.0, 21.2, 21.4],
},
modelTimes: ["12:00", "13:00", "14:00", "15:00"],
modelCurves: { ECMWF: [23.8, 24.4, 24.7, 24.0] },
} as any);
const freshAnkaraObservation = observationPayloadToSnapshot({
city: "ankara",
name: "ankara",
display_name: "Ankara",
local_date: "2026-06-23",
local_time: "13:30",
utc_offset_seconds: 3 * 60 * 60,
current: {
temp: 24.5,
source_code: "metar",
source_label: "METAR",
settlement_source: "metar",
settlement_source_label: "METAR",
station_code: "LTAC",
obs_time: "2026-06-23T10:30:00Z",
},
airport_current: {
temp: 24.5,
source_code: "metar",
source_label: "METAR",
station_code: "LTAC",
obs_time: "2026-06-23T10:30:00Z",
},
airport_primary: {
temp: 24.5,
source_code: "metar",
source_label: "METAR",
station_code: "LTAC",
obs_time: "2026-06-23T10:30:00Z",
},
timeseries: {
metar_today_obs: [{ time: "13:30", temp: 24.5 }],
},
metar_today_obs: [{ time: "13:30", temp: 24.5 }],
} as any);
const refreshedAnkaraDetail = mergeObservationSnapshotIntoHourly(
staleAnkaraDetail,
freshAnkaraObservation,
);
const refreshedAnkaraChart = buildFullDayChartData(
{
city: "ankara",
local_date: "2026-06-23",
local_time: "13:51",
tz_offset_seconds: 3 * 60 * 60,
airport: "LTAC",
temp_symbol: "°C",
} as any,
refreshedAnkaraDetail,
false,
);
const refreshedAnkaraAirportSeries = refreshedAnkaraChart.series.find((item) => item.key === "madis");
assert(
!refreshedAnkaraAirportSeries?.values.some((value) => value === 27.1),
"Ankara live observation refresh should discard stale cached MGM airport-primary points when the latest source is METAR",
);
assert(
refreshedAnkaraAirportSeries?.label === "LTAC METAR",
`Ankara refreshed airport-primary curve should be labelled as LTAC METAR, got ${refreshedAnkaraAirportSeries?.label}`,
);
const guangzhouRunwayWithBadMadisChart = buildFullDayChartData(
{
city: "guangzhou",
@@ -901,6 +901,16 @@ function canonicalAirportPrimarySourceLabel(hourly: ChartRenderState) {
return "";
}
function airportPrimaryHasMetarSource(hourly: ChartRenderState) {
const primary = hourly?.airportPrimary;
const tokens = [
primary?.source_code,
primary?.source_label,
(primary as any)?.source,
].map((value) => String(value || "").trim().toLowerCase());
return tokens.some((value) => value === "metar" || value.includes(" metar"));
}
function airportCodeForSeriesLabel(
hourly: ChartRenderState,
row?: ScanOpportunityRow | null,
@@ -943,12 +953,15 @@ function airportPrimarySeriesLabel(
const cityKey = normalizeCityKey(row?.city);
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
if (canonicalLabel === "MGM") return canonicalLabel;
const stationCode = airportCodeForSeriesLabel(hourly, row);
if (airportPrimaryHasMetarSource(hourly)) {
return stationCode ? `${stationCode} METAR` : "METAR";
}
if ((cityKey === "ankara" || cityKey === "istanbul") && (!canonicalLabel || canonicalLabel === "NOAA MADIS")) {
return "MGM";
}
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel;
const stationCode = airportCodeForSeriesLabel(hourly, row);
const isUsAirport = isUsAirportCode(stationCode);
if (!isUsAirport) {
if (canonicalLabel && canonicalLabel !== "NOAA MADIS") return canonicalLabel;
@@ -1316,6 +1329,17 @@ function conditionObservationTime(source: AirportCurrentConditions | null | unde
);
}
function conditionObservationSourceKey(source: AirportCurrentConditions | null | undefined) {
const tokens = [
source?.source_code,
(source as any)?.source,
source?.source_label,
].map((value) => String(value || "").trim().toLowerCase());
if (tokens.some((value) => value === "mgm" || value.includes("turkey_mgm"))) return "mgm";
if (tokens.some((value) => value === "metar" || value.includes(" metar"))) return "metar";
return tokens.find(Boolean) || "";
}
function mergeAirportCondition(
base: AirportCurrentConditions | null | undefined,
live: AirportCurrentConditions | null | undefined,
@@ -1338,6 +1362,19 @@ function mergeAirportCondition(
return merged;
}
function airportPrimaryObservationSourceChanged(
base: ChartRenderState,
live: ChartRenderState,
) {
const baseSource =
conditionObservationSourceKey(base?.airportPrimary) ||
conditionObservationSourceKey(base?.airportCurrent);
const liveSource =
conditionObservationSourceKey(live?.airportPrimary) ||
conditionObservationSourceKey(live?.airportCurrent);
return Boolean(baseSource && liveSource && baseSource !== liveSource);
}
function runwayHistoryPointKey(point: Record<string, unknown>) {
const time = String(
point.timestamp ??
@@ -1677,6 +1714,10 @@ function mergeHourlyWithLiveObservations(
runway_plate_history: runwayPlateHistory,
} as AmosData
: detailSource.amos;
const useLiveAirportPrimary =
airportPrimaryObservationSourceChanged(base, live) &&
Array.isArray(live.airportPrimaryTodayObs) &&
live.airportPrimaryTodayObs.length > 0;
return {
...detailSource,
localDate,
@@ -1696,16 +1737,22 @@ function mergeHourlyWithLiveObservations(
: forecastFallback.probabilities || null,
runwayPlateHistory,
amos,
airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
airportPrimary: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate),
airportCurrent: useLiveAirportPrimary
? live.airportCurrent || live.airportPrimary || null
: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
airportPrimary: useLiveAirportPrimary
? live.airportPrimary || live.airportCurrent || null
: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate),
settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined,
settlementStationCode: detailSource.settlementStationCode || forecastFallback.settlementStationCode || row?.metar_context?.station || null,
settlementStationLabel: detailSource.settlementStationLabel || forecastFallback.settlementStationLabel || null,
metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined,
airportPrimaryTodayObs: mergeRawObservationPoints(
base.airportPrimaryTodayObs,
live.airportPrimaryTodayObs,
),
airportPrimaryTodayObs: useLiveAirportPrimary
? live.airportPrimaryTodayObs
: mergeRawObservationPoints(
base.airportPrimaryTodayObs,
live.airportPrimaryTodayObs,
),
};
}
@@ -324,6 +324,9 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<Link href="/docs/chart-guide" className="hover:text-slate-950">
{isEn ? "Guide" : "读图"}
</Link>
<Link href="/briefs" className="hover:text-slate-950">
{isEn ? "Briefs" : "简报"}
</Link>
<a href="#pricing" className="hover:text-slate-950">
{isEn ? "Pricing" : "定价"}
</a>
@@ -104,6 +104,8 @@ export function runTests() {
"landing supported cities must render names and station codes from the generated city groups",
);
assert(source.includes("#contact"), "landing navigation must expose the contact section");
assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets");
assert(source.includes('isEn ? "Briefs" : "简报"'), "landing page must label the public brief entry in both languages");
assert(source.includes('id="contact"'), "landing page must include a contact section");
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
@@ -0,0 +1,23 @@
import Script from "next/script";
const MICROSOFT_CLARITY_PROJECT_ID = "xbydcq2lu2";
const MICROSOFT_CLARITY_SCRIPT = `
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${MICROSOFT_CLARITY_PROJECT_ID}");
`;
export function MicrosoftClarity() {
if (process.env.NODE_ENV !== "production") {
return null;
}
return (
<Script id="microsoft-clarity" strategy="afterInteractive">
{MICROSOFT_CLARITY_SCRIPT}
</Script>
);
}
@@ -0,0 +1,34 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const componentPath = path.join(projectRoot, "components", "observability", "MicrosoftClarity.tsx");
const layoutPath = path.join(projectRoot, "app", "layout.tsx");
assert(fs.existsSync(componentPath), "Microsoft Clarity component must exist");
const component = fs.readFileSync(componentPath, "utf8");
const layout = fs.readFileSync(layoutPath, "utf8");
assert(
component.includes('import Script from "next/script"') &&
component.includes('strategy="afterInteractive"'),
"Microsoft Clarity must use next/script and load after hydration",
);
assert(
component.includes("xbydcq2lu2") &&
component.includes("https://www.clarity.ms/tag/") &&
component.includes('window, document, "clarity", "script"'),
"Microsoft Clarity must use the configured PolyWeather project script",
);
assert(
layout.includes("@/components/observability/MicrosoftClarity") &&
layout.includes("<MicrosoftClarity />"),
"root layout must render Microsoft Clarity globally",
);
}
@@ -25,16 +25,21 @@ export function runTests() {
assert(
opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') &&
opsApi.includes("content_events?:") &&
opsApi.includes("contentEvents") &&
opsApi.includes("diagnostics?:") &&
opsApi.includes("traffic?:") &&
opsApi.includes("uniqueActors"),
"ops funnel API client must preserve the full standard funnel and expose diagnostics/traffic dimensions",
"ops funnel API client must preserve the standard funnel and expose content/diagnostics/traffic dimensions",
);
assert(
analyticsPage.includes("落地页访问") &&
analyticsPage.includes("进入终端") &&
analyticsPage.includes("注册成功") &&
analyticsPage.includes("鉴权降级") &&
analyticsPage.includes("公开内容获客") &&
analyticsPage.includes("Brief 浏览") &&
analyticsPage.includes("briefCtaClick?.total") &&
analyticsPage.includes("来源与设备") &&
analyticsPage.includes("paymentSuccess?.count") &&
!analyticsPage.includes("总注册") &&
@@ -28,10 +28,17 @@ type AuthDiagnostic = {
unique_actors?: number;
by_reason?: TopItem[];
};
type ContentEventSummary = {
total?: number;
unique_users?: number;
unique_actors?: number;
};
export function AnalyticsPageClient() {
const [loading, setLoading] = useState(true);
const [funnel, setFunnel] = useState<FunnelStep[]>([]);
const [contentEvents, setContentEvents] = useState<Record<string, ContentEventSummary>>({});
const [content, setContent] = useState<Record<string, TopItem[]>>({});
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
const [rates, setRates] = useState<Record<string, number> | null>(null);
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
@@ -42,6 +49,8 @@ export function AnalyticsPageClient() {
try {
const data = await opsApi.funnel(days);
setFunnel(data.steps);
setContentEvents(data.contentEvents ?? {});
setContent(data.content ?? {});
setDiagnostics(data.diagnostics ?? {});
setRates(data.rates ?? null);
setTraffic(data.traffic ?? {});
@@ -65,6 +74,10 @@ export function AnalyticsPageClient() {
const trialCreated = stepByKey.trial_created;
const paymentStart = stepByKey.payment_start;
const paymentSuccess = stepByKey.payment_success;
const briefView = contentEvents.brief_view;
const briefCtaClick = contentEvents.brief_cta_click;
const methodologyView = contentEvents.methodology_view;
const socialOutboundClick = contentEvents.social_outbound_click;
const overallRate =
landingView?.uniqueActors && paymentSuccess?.uniqueActors
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
@@ -141,6 +154,56 @@ export function AnalyticsPageClient() {
</div>
)}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<ContentMetricCard
label="Brief 浏览"
total={briefView?.total}
uniqueActors={briefView?.unique_actors}
/>
<ContentMetricCard
label="Brief CTA"
total={briefCtaClick?.total}
uniqueActors={briefCtaClick?.unique_actors}
/>
<ContentMetricCard
label="方法论阅读"
total={methodologyView?.total}
uniqueActors={methodologyView?.unique_actors}
/>
<ContentMetricCard
label="社交外链"
total={socialOutboundClick?.total}
uniqueActors={socialOutboundClick?.unique_actors}
/>
</div>
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
{[
["内容路径", content.paths ?? []],
["Brief 城市", content.cities ?? []],
].map(([title, rows]) => (
<div key={String(title)} className="space-y-2 rounded-lg border border-white/10 p-3">
<div className="text-xs font-bold text-slate-500">{String(title)}</div>
{(rows as TopItem[]).length === 0 ? (
<div className="text-xs text-slate-500"></div>
) : (
<ul className="space-y-1.5">
{(rows as TopItem[]).slice(0, 5).map((item) => (
<li key={item.name} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
<span className="font-mono text-blue-600">{item.count}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
@@ -243,3 +306,21 @@ export function AnalyticsPageClient() {
</div>
);
}
function ContentMetricCard({
label,
total,
uniqueActors,
}: {
label: string;
total?: number;
uniqueActors?: number;
}) {
return (
<div className="rounded-lg border border-white/10 p-4">
<div className="text-xs text-slate-500">{label}</div>
<div className="text-xl font-bold text-white">{total ?? 0}</div>
<div className="mt-0.5 text-xs text-slate-500"> {uniqueActors ?? 0}</div>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { useEffect, type ReactNode } from "react";
type PublicContentEvent =
| "brief_view"
| "brief_cta_click"
| "methodology_view"
| "social_outbound_click";
type AnalyticsPayload = Record<string, unknown>;
async function emitPublicContentEvent(
eventType: PublicContentEvent,
payload: AnalyticsPayload,
onceKey?: string,
) {
const { markAnalyticsOnce, trackAppEvent } = await import("@/lib/app-analytics");
if (onceKey && !markAnalyticsOnce(`public-content:${onceKey}`)) return;
trackAppEvent(eventType, payload);
}
export function PublicContentAnalytics({
eventType,
onceKey,
payload,
}: {
eventType: PublicContentEvent;
onceKey?: string;
payload: AnalyticsPayload;
}) {
const payloadKey = JSON.stringify(payload);
useEffect(() => {
const parsedPayload = JSON.parse(payloadKey) as AnalyticsPayload;
void emitPublicContentEvent(eventType, parsedPayload, onceKey);
}, [eventType, onceKey, payloadKey]);
return null;
}
export function PublicContentCta({
children,
className,
href,
payload,
}: {
children: ReactNode;
className?: string;
href: string;
payload: AnalyticsPayload;
}) {
return (
<Link
className={className}
href={href}
onClick={() => {
void emitPublicContentEvent("brief_cta_click", payload);
}}
>
{children}
</Link>
);
}
export function PublicContentOutboundLink({
children,
className,
href,
payload,
}: {
children: ReactNode;
className?: string;
href: string;
payload: AnalyticsPayload;
}) {
return (
<a
className={className}
href={href}
onClick={() => {
void emitPublicContentEvent("social_outbound_click", payload);
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
}
@@ -0,0 +1,506 @@
import Link from "next/link";
import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
import type { LandingLocale } from "@/components/landing/landingLocale";
import {
METHODOLOGY_PAGES,
PUBLIC_CONTENT_COPY,
PUBLIC_BRIEFS,
SOURCE_PAGES,
absolutePublicUrl,
briefPath,
methodologyPath,
sourcePath,
localizeBrief,
localizeBriefs,
localizeMethodologyPage,
localizeSourcePage,
type MethodologyPage,
type PublicBrief,
type SourcePage,
} from "@/content/public-content";
import {
PublicContentAnalytics,
PublicContentCta,
PublicContentOutboundLink,
} from "./PublicContentAnalytics";
const pageShell =
"min-h-screen bg-[#f4f7fb] text-slate-950";
const contentWrap =
"mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-6 sm:px-6 lg:px-8";
const panel =
"rounded-lg border border-slate-200 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.05)]";
const sectionTitle =
"text-sm font-semibold uppercase tracking-[0.08em] text-slate-500";
const bodyText = "text-sm leading-6 text-slate-700";
const primaryButton =
"inline-flex min-h-10 items-center justify-center rounded-md bg-slate-950 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800";
const secondaryButton =
"inline-flex min-h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 hover:bg-slate-50";
function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) {
const copy = PUBLIC_CONTENT_COPY[locale];
return (
<header className="border-b border-slate-200 bg-white/90">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<Link className="text-base font-black text-slate-950" href="/">
PolyWeather
</Link>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
{locale === "en-US" ? "Briefs" : "简报"}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
{copy.methodology}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
{copy.sources}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
{copy.docs}
</Link>
</nav>
<LandingLocaleToggle locale={locale} />
</div>
</div>
</header>
);
}
function PageIntro({
eyebrow,
title,
description,
}: {
eyebrow: string;
title: string;
description: string;
}) {
return (
<section className="grid gap-4 border-b border-slate-200 bg-white">
<div className={`${contentWrap} py-10 sm:py-12`}>
<p className={sectionTitle}>{eyebrow}</p>
<div className="max-w-3xl space-y-4">
<h1 className="text-3xl font-black leading-tight text-slate-950 sm:text-4xl">
{title}
</h1>
<p className="text-base leading-7 text-slate-700">{description}</p>
</div>
</div>
</section>
);
}
function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
return (
<div className="flex flex-wrap gap-2">
{slugs.map((slug) => {
const source = SOURCE_PAGES.find((entry) => entry.slug === slug);
if (!source) return null;
const localizedSource = localizeSourcePage(source, locale);
return (
<Link
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
href={sourcePath(localizedSource)}
key={slug}
>
{localizedSource.title}
</Link>
);
})}
</div>
);
}
function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
return (
<div className="flex flex-wrap gap-2">
{slugs.map((slug) => {
const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug);
if (!page) return null;
const localizedPage = localizeMethodologyPage(page, locale);
return (
<Link
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
href={methodologyPath(localizedPage)}
key={slug}
>
{localizedPage.title}
</Link>
);
})}
</div>
);
}
function BriefCard({
brief,
locale = "en-US",
readBriefLabel,
}: {
brief: PublicBrief;
locale?: LandingLocale;
readBriefLabel: string;
}) {
return (
<article className={`${panel} flex flex-col gap-5 p-5`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-blue-700">
{brief.cityName} / {brief.date}
</p>
<h2 className="mt-2 text-xl font-black text-slate-950">
<Link href={briefPath(brief)}>{brief.title}</Link>
</h2>
</div>
<span className="rounded-md bg-emerald-50 px-2.5 py-1 text-xs font-bold text-emerald-800">
{brief.settlementSource}
</span>
</div>
<p className={bodyText}>{brief.description}</p>
<div className="grid gap-3 sm:grid-cols-3">
{brief.signals.map((signal) => (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3" key={signal.label}>
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
<p className="mt-1 font-mono text-lg font-bold text-slate-950">{signal.value}</p>
<p className="mt-1 text-xs leading-5 text-slate-600">{signal.detail}</p>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Link className={secondaryButton} href={briefPath(brief)}>
{readBriefLabel}
</Link>
<SourceLinks locale={locale} slugs={brief.sourceSlugs.slice(0, 2)} />
</div>
</article>
);
}
export function BriefsIndexPageView({ locale = "en-US" }: { locale?: LandingLocale }) {
const copy = PUBLIC_CONTENT_COPY[locale];
const briefs = localizeBriefs(locale);
return (
<div className={pageShell}>
<PublicHeader locale={locale} />
<PageIntro
description={copy.briefIndexDescription}
eyebrow={copy.briefIndexEyebrow}
title={copy.briefIndexTitle}
/>
<div className={contentWrap}>
<section className="grid gap-4">
{briefs.map((brief) => (
<BriefCard
brief={brief}
key={`${brief.city}-${brief.date}`}
locale={locale}
readBriefLabel={copy.readBrief}
/>
))}
</section>
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>{copy.methodology}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
{copy.methodologyPanelTitle}
</h2>
<p className={`${bodyText} mt-3`}>
{copy.methodologyPanelBody}
</p>
<div className="mt-4">
<MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]} />
</div>
</div>
<div>
<p className={sectionTitle}>{copy.sourceNotes}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
{copy.sourcePanelTitle}
</h2>
<p className={`${bodyText} mt-3`}>
{copy.sourcePanelBody}
</p>
<div className="mt-4">
<SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]} />
</div>
</div>
</section>
</div>
</div>
);
}
export function BriefDetailPageView({
brief,
locale = "en-US",
}: {
brief: PublicBrief;
locale?: LandingLocale;
}) {
const copy = PUBLIC_CONTENT_COPY[locale];
const localizedBrief = localizeBrief(brief, locale);
return (
<div className={pageShell}>
<PublicContentAnalytics
eventType="brief_view"
onceKey={`brief:${localizedBrief.city}:${localizedBrief.date}`}
payload={{ city: localizedBrief.city, date: localizedBrief.date, source: localizedBrief.settlementSource }}
/>
<PublicHeader locale={locale} />
<PageIntro
description={localizedBrief.description}
eyebrow={`${localizedBrief.cityName}, ${localizedBrief.countryName} / ${localizedBrief.date}`}
title={localizedBrief.title}
/>
<div className={contentWrap}>
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.9fr]">
<article className={`${panel} p-5`}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{localizedBrief.signals.map((signal) => (
<div className="rounded-md border border-slate-200 bg-slate-50 p-4" key={signal.label}>
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
<p className="mt-1 font-mono text-2xl font-black text-slate-950">{signal.value}</p>
<p className="mt-2 text-xs leading-5 text-slate-600">{signal.detail}</p>
</div>
))}
</div>
<div className="mt-6 grid gap-5">
<BriefSection title={copy.detailLabels.debRead} body={localizedBrief.debRead} />
<BriefSection title={copy.detailLabels.settlementSourceRead} body={localizedBrief.sourceRead} />
<BriefSection title={copy.detailLabels.modelContext} body={localizedBrief.modelRead} />
<BriefSection title={copy.detailLabels.riskNotes} body={localizedBrief.riskRead} />
</div>
</article>
<aside className={`${panel} h-fit p-5`}>
<p className={sectionTitle}>{copy.snapshot}</p>
<dl className="mt-4 grid gap-3 text-sm">
<InfoRow label={copy.market} value={localizedBrief.market} />
<InfoRow label={copy.settlementSource} value={localizedBrief.settlementSource} />
<InfoRow label={copy.updated} value={formatDateTime(localizedBrief.updatedAt, locale)} />
<InfoRow label={copy.freshness} value={localizedBrief.dataFreshness} />
</dl>
<div className="mt-5 flex flex-col gap-3">
<PublicContentCta
className={primaryButton}
href="/terminal"
payload={{ city: localizedBrief.city, date: localizedBrief.date, cta: "terminal" }}
>
{localizedBrief.primaryCtaLabel}
</PublicContentCta>
<Link className={secondaryButton} href="/briefs">
{copy.allPublicBriefs}
</Link>
</div>
</aside>
</section>
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
<div className={`${panel} p-5`}>
<p className={sectionTitle}>{copy.checksBeforeActing}</p>
<ul className="mt-4 grid gap-3">
{localizedBrief.checkpoints.map((checkpoint) => (
<li className={bodyText} key={checkpoint}>
{checkpoint}
</li>
))}
</ul>
</div>
<div className={`${panel} p-5`}>
<p className={sectionTitle}>{copy.distributionCopy}</p>
<p className={`${bodyText} mt-4`}>{localizedBrief.distributionText}</p>
<div className="mt-4">
<PublicContentOutboundLink
className={secondaryButton}
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(localizedBrief)))}`}
payload={{ city: localizedBrief.city, date: localizedBrief.date, destination: "x_intent" }}
>
{copy.shareOnX}
</PublicContentOutboundLink>
</div>
</div>
</section>
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>{copy.methodologyLinks}</p>
<div className="mt-4">
<MethodologyLinks locale={locale} slugs={localizedBrief.methodologySlugs} />
</div>
</div>
<div>
<p className={sectionTitle}>{copy.sourceLinks}</p>
<div className="mt-4">
<SourceLinks locale={locale} slugs={localizedBrief.sourceSlugs} />
</div>
</div>
</section>
<p className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
{localizedBrief.notFinancialAdvice}
</p>
</div>
</div>
);
}
function BriefSection({ body, title }: { body: string; title: string }) {
return (
<section>
<h2 className="text-lg font-black text-slate-950">{title}</h2>
<p className={`${bodyText} mt-2`}>{body}</p>
</section>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1 border-b border-slate-100 pb-3 last:border-b-0 last:pb-0">
<dt className="text-xs font-semibold uppercase tracking-[0.08em] text-slate-500">{label}</dt>
<dd className="leading-6 text-slate-800">{value}</dd>
</div>
);
}
export function MethodologyIndexPageView() {
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro
description="Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis."
eyebrow="Methodology"
title="How PolyWeather reads weather markets"
/>
<div className={contentWrap}>
<section className="grid gap-4 md:grid-cols-2">
{METHODOLOGY_PAGES.map((page) => (
<article className={`${panel} p-5`} key={page.slug}>
<p className={sectionTitle}>{formatDate(page.updatedAt)}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
<Link href={methodologyPath(page)}>{page.title}</Link>
</h2>
<p className={`${bodyText} mt-3`}>{page.description}</p>
<Link className={`${secondaryButton} mt-5`} href={methodologyPath(page)}>
Read methodology
</Link>
</article>
))}
</section>
</div>
</div>
);
}
export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
return (
<div className={pageShell}>
<PublicContentAnalytics
eventType="methodology_view"
onceKey={`methodology:${page.slug}`}
payload={{ slug: page.slug, content_type: "methodology" }}
/>
<PublicHeader />
<PageIntro description={page.description} eyebrow="Methodology" title={page.title} />
<div className={contentWrap}>
<article className={`${panel} p-5`}>
<p className="max-w-3xl text-base leading-7 text-slate-700">{page.summary}</p>
<div className="mt-8 grid gap-8">
{page.sections.map((section) => (
<section key={section.heading}>
<h2 className="text-xl font-black text-slate-950">{section.heading}</h2>
<p className={`${bodyText} mt-3`}>{section.body}</p>
<ul className="mt-4 grid gap-3">
{section.bullets.map((bullet) => (
<li className={bodyText} key={bullet}>
{bullet}
</li>
))}
</ul>
</section>
))}
</div>
</article>
</div>
</div>
);
}
export function SourcesIndexPageView() {
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro
description="Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence."
eyebrow="Sources"
title="Weather source notes for public audit"
/>
<div className={contentWrap}>
<section className="grid gap-4 md:grid-cols-2">
{SOURCE_PAGES.map((source) => (
<article className={`${panel} p-5`} key={source.slug}>
<p className={sectionTitle}>{source.operator}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
<Link href={sourcePath(source)}>{source.title}</Link>
</h2>
<p className={`${bodyText} mt-3`}>{source.description}</p>
<Link className={`${secondaryButton} mt-5`} href={sourcePath(source)}>
Read source note
</Link>
</article>
))}
</section>
</div>
</div>
);
}
export function SourceDetailPageView({ source }: { source: SourcePage }) {
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro description={source.description} eyebrow="Source note" title={source.title} />
<div className={contentWrap}>
<article className={`${panel} grid gap-6 p-5 lg:grid-cols-[0.9fr_1.4fr]`}>
<dl className="grid gap-3 text-sm">
<InfoRow label="Operator" value={source.operator} />
<InfoRow label="Coverage" value={source.coverage} />
<InfoRow label="Cadence" value={source.cadence} />
<InfoRow label="Settlement use" value={source.settlementUse} />
</dl>
<div>
<p className={sectionTitle}>Reliability notes</p>
<ul className="mt-4 grid gap-3">
{source.reliabilityNotes.map((note) => (
<li className={bodyText} key={note}>
{note}
</li>
))}
</ul>
<div className="mt-6">
<p className={sectionTitle}>Related methodology</p>
<div className="mt-4">
<MethodologyLinks slugs={source.relatedMethodologySlugs} />
</div>
</div>
</div>
</article>
</div>
</div>
);
}
function formatDateTime(value: string, locale: LandingLocale = "en-US") {
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("en", {
dateStyle: "medium",
}).format(new Date(value));
}
@@ -0,0 +1,126 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const root = process.cwd();
const contentPath = path.join(root, "content", "public-content.ts");
const briefsIndexPath = path.join(root, "app", "briefs", "page.tsx");
const briefDetailPath = path.join(root, "app", "briefs", "[city]", "[date]", "page.tsx");
const methodologyIndexPath = path.join(root, "app", "methodology", "page.tsx");
const methodologyDetailPath = path.join(root, "app", "methodology", "[slug]", "page.tsx");
const sourcesIndexPath = path.join(root, "app", "sources", "page.tsx");
const sourceDetailPath = path.join(root, "app", "sources", "[slug]", "page.tsx");
const analyticsPath = path.join(root, "lib", "app-analytics.ts");
const analyticsIslandPath = path.join(root, "components", "public-content", "PublicContentAnalytics.tsx");
const publicPagesPath = path.join(root, "components", "public-content", "PublicContentPages.tsx");
const sitemapPath = path.join(root, "app", "sitemap.ts");
for (const requiredPath of [
contentPath,
briefsIndexPath,
briefDetailPath,
methodologyIndexPath,
methodologyDetailPath,
sourcesIndexPath,
sourceDetailPath,
analyticsIslandPath,
publicPagesPath,
]) {
assert(fs.existsSync(requiredPath), `${path.relative(root, requiredPath)} must exist`);
}
const content = fs.readFileSync(contentPath, "utf8");
const briefDetail = fs.readFileSync(briefDetailPath, "utf8");
const methodologyDetail = fs.readFileSync(methodologyDetailPath, "utf8");
const sourceDetail = fs.readFileSync(sourceDetailPath, "utf8");
const analytics = fs.readFileSync(analyticsPath, "utf8");
const analyticsIsland = fs.readFileSync(analyticsIslandPath, "utf8");
const publicPages = fs.readFileSync(publicPagesPath, "utf8");
const sitemap = fs.readFileSync(sitemapPath, "utf8");
const briefsIndex = fs.readFileSync(briefsIndexPath, "utf8");
assert(
content.includes("PUBLIC_BRIEFS") &&
content.includes("METHODOLOGY_PAGES") &&
content.includes("SOURCE_PAGES") &&
content.includes('"ankara"') &&
content.includes('"deb"') &&
content.includes('"mgm"'),
"public content module must define sample briefs plus DEB and MGM public pages",
);
assert(
content.includes("notFinancialAdvice") &&
content.includes("updatedAt") &&
content.includes("settlementSource") &&
content.includes("distributionText"),
"public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy",
);
assert(
content.includes("PUBLIC_CONTENT_COPY") &&
content.includes('"zh-CN"') &&
content.includes('"en-US"') &&
content.includes("公开天气市场简报") &&
content.includes("安卡拉") &&
content.includes("阅读简报"),
"public brief content must provide Chinese and English localized copy",
);
assert(
briefDetail.includes("generateStaticParams") &&
briefDetail.includes("generateMetadata") &&
briefDetail.includes("application/ld+json") &&
briefDetail.includes("BreadcrumbList") &&
briefDetail.includes("Article") &&
briefDetail.includes("notFound()"),
"brief detail route must be statically indexable with metadata, JSON-LD, breadcrumbs, and 404 handling",
);
assert(
methodologyDetail.includes("generateStaticParams") &&
methodologyDetail.includes("TechArticle") &&
methodologyDetail.includes("application/ld+json") &&
sourceDetail.includes("Dataset") &&
sourceDetail.includes("application/ld+json"),
"methodology and source detail routes must expose structured data for GEO/SEO",
);
assert(
publicPages.includes('MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]}') &&
publicPages.includes('SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]}') &&
briefsIndex.includes("Weather Market Brief"),
"brief index must cross-link to DEB methodology and settlement source pages",
);
assert(
publicPages.includes("LandingLocaleToggle") &&
publicPages.includes("localizeBrief") &&
publicPages.includes("PUBLIC_CONTENT_COPY") &&
publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'),
"public content pages must render the shared language toggle and localized brief copy",
);
assert(
analytics.includes('"brief_view"') &&
analytics.includes('"brief_cta_click"') &&
analytics.includes('"methodology_view"') &&
analytics.includes('"social_outbound_click"'),
"analytics event union must include public content acquisition events",
);
assert(
analyticsIsland.startsWith('"use client"') &&
analyticsIsland.includes("trackAppEvent") &&
analyticsIsland.includes("brief_view") &&
analyticsIsland.includes("brief_cta_click") &&
analyticsIsland.includes("methodology_view") &&
analyticsIsland.includes("social_outbound_click"),
"public content analytics must be isolated in a client island and emit the new events",
);
assert(
sitemap.includes("PUBLIC_BRIEFS") &&
sitemap.includes("METHODOLOGY_PAGES") &&
sitemap.includes("SOURCE_PAGES") &&
sitemap.includes("/briefs") &&
sitemap.includes("/methodology/") &&
sitemap.includes("/sources/"),
"sitemap must enumerate public content assets for search and answer engines",
);
}
+736
View File
@@ -0,0 +1,736 @@
import type { LandingLocale } from "@/components/landing/landingLocale";
export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top";
export type PublicBriefSignal = {
label: string;
value: string;
detail: string;
};
export type PublicBrief = {
city: string;
cityName: string;
countryName: string;
date: string;
title: string;
description: string;
market: string;
settlementSource: string;
updatedAt: string;
publishedAt: string;
dataFreshness: string;
debRead: string;
sourceRead: string;
modelRead: string;
riskRead: string;
notFinancialAdvice: string;
distributionText: string;
primaryCtaLabel: string;
sourceSlugs: string[];
methodologySlugs: string[];
signals: PublicBriefSignal[];
checkpoints: string[];
};
export type MethodologyPage = {
slug: string;
title: string;
description: string;
updatedAt: string;
summary: string;
sections: Array<{
heading: string;
body: string;
bullets: string[];
}>;
};
export type SourcePage = {
slug: string;
title: string;
description: string;
updatedAt: string;
operator: string;
coverage: string;
cadence: string;
settlementUse: string;
reliabilityNotes: string[];
relatedMethodologySlugs: string[];
};
export type PublicContentCopy = {
allPublicBriefs: string;
briefIndexDescription: string;
briefIndexEyebrow: string;
briefIndexTitle: string;
checksBeforeActing: string;
distributionCopy: string;
docs: string;
freshness: string;
market: string;
methodology: string;
methodologyIndexDescription: string;
methodologyIndexEyebrow: string;
methodologyIndexTitle: string;
methodologyLinks: string;
methodologyPanelBody: string;
methodologyPanelTitle: string;
openLiveTerminal: string;
readBrief: string;
readMethodology: string;
readSourceNote: string;
settlementSource: string;
shareOnX: string;
snapshot: string;
sourceIndexDescription: string;
sourceIndexEyebrow: string;
sourceIndexTitle: string;
sourceLinks: string;
sourceNotes: string;
sourcePanelBody: string;
sourcePanelTitle: string;
sources: string;
updated: string;
detailLabels: {
debRead: string;
modelContext: string;
riskNotes: string;
settlementSourceRead: string;
};
};
export const PUBLIC_CONTENT_COPY: Record<LandingLocale, PublicContentCopy> = {
"zh-CN": {
allPublicBriefs: "全部公开简报",
briefIndexDescription:
"公开天气市场简报把选定城市的市场读数整理为可索引证据:结算源、DEB 背景、模型分歧、新鲜度备注,以及明确的研究免责声明。",
briefIndexEyebrow: "天气市场简报",
briefIndexTitle: "公开天气市场简报",
checksBeforeActing: "行动前检查",
distributionCopy: "分发文案",
docs: "文档",
freshness: "新鲜度",
market: "市场",
methodology: "方法",
methodologyIndexDescription:
"公开方法页解释 PolyWeather 如何处理 DEB 融合、结算源优先级、新鲜度和来源校验。",
methodologyIndexEyebrow: "方法",
methodologyIndexTitle: "PolyWeather 如何读取天气市场",
methodologyLinks: "方法链接",
methodologyPanelBody:
"简报会交叉链接 DEB 方法和结算源优先级页面,让读者能审计为什么 PolyWeather 不把通用城市预报直接当作市场真实值。",
methodologyPanelTitle: "公开读数如何产生",
openLiveTerminal: "打开实时终端",
readBrief: "阅读简报",
readMethodology: "阅读方法",
readSourceNote: "阅读来源说明",
settlementSource: "结算源",
shareOnX: "分享到 X",
snapshot: "快照",
sourceIndexDescription:
"来源页把官方观测、机场观测和模型指引分开展示,方便读者审计 PolyWeather 为什么优先使用与结算相关的证据。",
sourceIndexEyebrow: "来源",
sourceIndexTitle: "用于公开审计的天气来源说明",
sourceLinks: "来源链接",
sourceNotes: "来源说明",
sourcePanelBody:
"来源页解释为什么 MGM、METAR、HKO、NOAA 和模型指引会在 PolyWeather 工作流中分开展示。",
sourcePanelTitle: "官方来源上下文",
sources: "来源",
updated: "更新",
detailLabels: {
debRead: "DEB 读数",
modelContext: "模型上下文",
riskNotes: "风险备注",
settlementSourceRead: "结算源读数",
},
},
"en-US": {
allPublicBriefs: "All public briefs",
briefIndexDescription:
"Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer.",
briefIndexEyebrow: "Weather Market Brief",
briefIndexTitle: "Public market briefs for temperature judgment",
checksBeforeActing: "Checks before acting",
distributionCopy: "Distribution copy",
docs: "Docs",
freshness: "Freshness",
market: "Market",
methodology: "Methodology",
methodologyIndexDescription:
"Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis.",
methodologyIndexEyebrow: "Methodology",
methodologyIndexTitle: "How PolyWeather reads weather markets",
methodologyLinks: "Methodology links",
methodologyPanelBody:
"Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.",
methodologyPanelTitle: "How the public read is produced",
openLiveTerminal: "Open live terminal",
readBrief: "Read brief",
readMethodology: "Read methodology",
readSourceNote: "Read source note",
settlementSource: "Settlement source",
shareOnX: "Share on X",
snapshot: "Snapshot",
sourceIndexDescription:
"Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence.",
sourceIndexEyebrow: "Sources",
sourceIndexTitle: "Weather source notes for public audit",
sourceLinks: "Source links",
sourceNotes: "Source notes",
sourcePanelBody:
"Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.",
sourcePanelTitle: "Official-source context",
sources: "Sources",
updated: "Updated",
detailLabels: {
debRead: "DEB read",
modelContext: "Model context",
riskNotes: "Risk notes",
settlementSourceRead: "Settlement-source read",
},
},
};
export const PUBLIC_BRIEFS: PublicBrief[] = [
{
city: "ankara",
cityName: "Ankara",
countryName: "Turkey",
date: "2026-06-24",
title: "Ankara Weather Market Brief - 24 Jun 2026",
description:
"A public market brief for Ankara maximum temperature judgment, focused on MGM settlement-source behavior, DEB blended forecast context, and anomaly checks.",
market: "Same-day maximum temperature judgment",
settlementSource: "MGM official station",
updatedAt: "2026-06-24T13:55:00+03:00",
publishedAt: "2026-06-24T13:55:00+03:00",
dataFreshness:
"Static public snapshot. Paid terminal users should verify the latest official observation and SSE replay state before acting.",
debRead:
"DEB kept the intraday high-temperature read below the isolated MGM spike and closer to the observed official range.",
sourceRead:
"MGM is treated as the primary settlement reference. A single 27.1 C point should be checked against adjacent official readings before it is accepted as a new high.",
modelRead:
"ECMWF was warmer than the DEB blend in the early afternoon window, but the public brief weights official observations above model-only movement.",
riskRead:
"Main risk is a late official update or a source-side correction that changes the recognized high after the public snapshot.",
notFinancialAdvice:
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
distributionText:
"Ankara 2026-06-24 public Weather Market Brief: MGM official readings favored a 24.5 C observed high over an isolated 27.1 C spike; DEB stayed below the outlier. Not financial advice.",
primaryCtaLabel: "Open live terminal",
sourceSlugs: ["mgm", "metar", "ecmwf"],
methodologySlugs: ["deb", "settlement-sources"],
signals: [
{
label: "Observed high so far",
value: "24.5 C",
detail: "Official-source value to compare against any isolated higher point.",
},
{
label: "Outlier under review",
value: "27.1 C",
detail: "A sudden single-source value needs neighboring-time validation.",
},
{
label: "DEB public read",
value: "Below spike",
detail: "Blend stayed closer to the verified observation band.",
},
],
checkpoints: [
"Check whether the suspected spike appears in the official high-temperature summary.",
"Compare adjacent MGM observations before treating a single point as settlement-relevant.",
"Review the paid terminal for live chart patches and source freshness before market close.",
],
},
{
city: "hong-kong",
cityName: "Hong Kong",
countryName: "Hong Kong",
date: "2026-06-24",
title: "Hong Kong Weather Market Brief - 24 Jun 2026",
description:
"A public brief showing how PolyWeather frames HKO/Cheung Chau/airport observations against DEB and model spread for maximum-temperature markets.",
market: "Urban and airport maximum temperature judgment",
settlementSource: "HKO official network",
updatedAt: "2026-06-24T18:00:00+08:00",
publishedAt: "2026-06-24T18:00:00+08:00",
dataFreshness:
"Static public snapshot. Live terminal values may differ as HKO and station caches refresh.",
debRead:
"DEB favored a narrow high-temperature window because live observations and model spread were aligned by late afternoon.",
sourceRead:
"HKO network observations remain the source family to reconcile before interpreting airport-only movement.",
modelRead:
"Model disagreement was limited, so source freshness and station selection mattered more than broad synoptic uncertainty.",
riskRead:
"Primary risk is station-specific heat retention during late afternoon or a late official summary revision.",
notFinancialAdvice:
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
distributionText:
"Hong Kong 2026-06-24 public Weather Market Brief: source selection and HKO freshness mattered more than model spread. Not financial advice.",
primaryCtaLabel: "Open live terminal",
sourceSlugs: ["hko", "metar"],
methodologySlugs: ["deb", "settlement-sources"],
signals: [
{
label: "Source family",
value: "HKO",
detail: "Use official station context before airport-only interpretation.",
},
{
label: "Model spread",
value: "Low",
detail: "Late-day uncertainty mainly came from station behavior.",
},
{
label: "Terminal need",
value: "Freshness",
detail: "Live source timestamps decide whether the public snapshot is still useful.",
},
],
checkpoints: [
"Verify HKO station timestamps before comparing market bands.",
"Separate airport METAR observations from settlement-source network readings.",
"Check terminal source health if the public snapshot is older than one refresh cycle.",
],
},
{
city: "new-york",
cityName: "New York",
countryName: "United States",
date: "2026-06-24",
title: "New York Weather Market Brief - 24 Jun 2026",
description:
"A public brief for New York temperature markets, connecting METAR, NOAA context, DEB blending, and late-day risk checks.",
market: "Airport-linked maximum temperature judgment",
settlementSource: "METAR and NOAA station context",
updatedAt: "2026-06-24T12:30:00-04:00",
publishedAt: "2026-06-24T12:30:00-04:00",
dataFreshness:
"Static public snapshot. Paid terminal users should check current METAR observations and official summaries.",
debRead:
"DEB weighted the latest observation trend against warmer model guidance instead of following the raw model high.",
sourceRead:
"METAR provides fast airport evidence, while NOAA context helps validate whether the airport value is representative.",
modelRead:
"Warm model guidance can be useful only after it is reconciled with live airport observations and cloud/wind context.",
riskRead:
"Primary risk is a short late-day break in cloud cover that lifts airport observations into a higher band.",
notFinancialAdvice:
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
distributionText:
"New York 2026-06-24 public Weather Market Brief: DEB blended warmer guidance against live airport evidence. Not financial advice.",
primaryCtaLabel: "Open live terminal",
sourceSlugs: ["metar", "noaa"],
methodologySlugs: ["deb", "settlement-sources"],
signals: [
{
label: "Fast evidence",
value: "METAR",
detail: "Airport observations define the short-cycle read.",
},
{
label: "Validation",
value: "NOAA",
detail: "Official context helps audit the final high-temperature interpretation.",
},
{
label: "DEB stance",
value: "Blend",
detail: "Do not follow a warm model run without live evidence confirmation.",
},
],
checkpoints: [
"Watch the last two METAR cycles before the daily high window ends.",
"Check whether model warmth is supported by cloud and wind observations.",
"Use official source context before final settlement interpretation.",
],
},
];
export const METHODOLOGY_PAGES: MethodologyPage[] = [
{
slug: "deb",
title: "DEB Forecast Methodology",
description:
"How PolyWeather frames DEB blended forecasts for prediction-market temperature decisions without replacing settlement-source evidence.",
updatedAt: "2026-06-24T00:00:00Z",
summary:
"DEB is the public name for PolyWeather's blended forecast layer. It reconciles model guidance, live observation momentum, source freshness, and station context so users can judge a high-temperature band with fewer single-model mistakes.",
sections: [
{
heading: "What DEB is for",
body:
"DEB is not a settlement oracle. It is a decision-support layer that makes the live observation path and model spread easier to compare.",
bullets: [
"Prefer settlement-source evidence when it conflicts with model-only guidance.",
"Treat stale or isolated source values as quality-control candidates.",
"Expose the forecast band and the reason a band is widening or narrowing.",
],
},
{
heading: "Inputs that matter",
body:
"The blend is useful because it combines different evidence classes instead of pretending one model run is enough.",
bullets: [
"Latest official or airport observations and their freshness.",
"Model consensus and disagreement across ECMWF, GFS, ICON, GEM, and local sources when available.",
"City-specific source behavior, station selection, and intraday high timing.",
],
},
{
heading: "How to read a DEB miss",
body:
"A DEB miss should be reviewed by separating source freshness, model spread, and settlement-source revisions.",
bullets: [
"If the observed high changed after a late source patch, classify it as a freshness or replay issue.",
"If every source was fresh but the high landed outside the band, review model weighting and local station features.",
"If one source jumped alone, audit neighboring observations before retraining around the outlier.",
],
},
],
},
{
slug: "settlement-sources",
title: "Settlement-Source Priority",
description:
"Why PolyWeather presents official settlement-related observations before generic weather API values.",
updatedAt: "2026-06-24T00:00:00Z",
summary:
"Prediction-market users care about the number that resolves the contract. PolyWeather therefore puts official station, airport, and operator-specific source behavior above broad consumer-weather averages.",
sections: [
{
heading: "Why generic weather values are not enough",
body:
"Consumer weather apps often smooth station data or display city-wide approximations. Market resolution can depend on a narrower official source.",
bullets: [
"A city label may hide multiple stations with different daily highs.",
"Airport METAR can update faster than a public summary, but may not be the final settlement source.",
"Official source revisions can matter more than a visually smooth forecast curve.",
],
},
{
heading: "What PolyWeather surfaces",
body:
"The terminal separates source labels, observation timestamps, forecast models, and freshness state so a user can audit the path to a number.",
bullets: [
"Settlement-source labels and station context on charts.",
"Source freshness, cache policy, and SSE patch visibility.",
"DEB forecast context shown next to live observations, not as a replacement for them.",
],
},
],
},
];
export const SOURCE_PAGES: SourcePage[] = [
{
slug: "mgm",
title: "MGM Weather Source",
description:
"PolyWeather source note for Turkish MGM observations used in Ankara-style temperature market analysis.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "Turkish State Meteorological Service",
coverage: "Turkey official station network, including Ankara market context.",
cadence: "Source cadence varies by station and publication path; terminal freshness checks are required.",
settlementUse:
"Used as the primary official-source family when Ankara markets reference Turkish official observations.",
reliabilityNotes: [
"Single-point spikes should be compared with neighboring timestamps before being accepted.",
"Official summaries can lag raw point observations.",
"Cache and SSE replay state should be checked when a value appears suddenly.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
{
slug: "metar",
title: "METAR Airport Observations",
description:
"PolyWeather source note for airport METAR observations used as fast evidence in temperature-market workflows.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "Airport weather observation network",
coverage: "Airport-linked observations across supported markets.",
cadence: "Often hourly or sub-hourly depending on airport and issuance behavior.",
settlementUse:
"Useful for fast evidence and airport-linked contracts; must be reconciled with the contract's exact settlement source.",
reliabilityNotes: [
"METAR can update faster than official daily summaries.",
"Airport exposure may differ from city-center official stations.",
"Late METAR cycles can change high-temperature judgment near market close.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
{
slug: "ecmwf",
title: "ECMWF Model Guidance",
description:
"PolyWeather source note for ECMWF model guidance as one input into DEB blended forecasts.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "European Centre for Medium-Range Weather Forecasts",
coverage: "Global numerical weather prediction guidance.",
cadence: "Model-run cadence depends on product and ingestion timing.",
settlementUse:
"Used for forecast context only. It does not replace live official observations for settlement interpretation.",
reliabilityNotes: [
"Model warmth or coolness should be validated against live observations.",
"Run-to-run shifts can be useful when source evidence has not yet settled.",
"Model spread should be shown beside, not above, settlement-source data.",
],
relatedMethodologySlugs: ["deb"],
},
{
slug: "hko",
title: "HKO Official Observations",
description:
"PolyWeather source note for Hong Kong Observatory observations in Hong Kong market analysis.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "Hong Kong Observatory",
coverage: "Hong Kong official observation network and station-specific context.",
cadence: "Cadence varies by observation product; terminal freshness checks remain required.",
settlementUse:
"Used as the official-source family for Hong Kong station and city-market interpretation.",
reliabilityNotes: [
"Station selection can materially change the maximum-temperature read.",
"Airport observations should be separated from broader HKO network readings.",
"Humidity, wind, and late-day sun breaks can affect final highs.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
{
slug: "noaa",
title: "NOAA Weather Context",
description:
"PolyWeather source note for NOAA context used to validate US weather-market observations and summaries.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "National Oceanic and Atmospheric Administration",
coverage: "United States official weather observations, summaries, and context products.",
cadence: "Cadence depends on product family and station reporting behavior.",
settlementUse:
"Used to audit and contextualize US official observations where contract rules reference NOAA/NWS data.",
reliabilityNotes: [
"Official summaries can arrive after fast airport observations.",
"Daily high interpretation should match the contract's station and time zone rules.",
"Use NOAA context to confirm whether an airport observation is representative.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
];
const BRIEF_LOCALIZATIONS: Record<string, Partial<PublicBrief>> = {
"ankara:2026-06-24": {
cityName: "安卡拉",
countryName: "土耳其",
title: "安卡拉天气市场简报 - 2026年6月24日",
description:
"安卡拉最高温公开简报,聚焦 MGM 结算源行为、DEB 融合预报背景和异常值复核。",
market: "当日最高温判断",
settlementSource: "MGM 官方站",
dataFreshness:
"静态公开快照。付费终端用户行动前应核对最新官方观测和 SSE replay 状态。",
debRead:
"DEB 将日内最高温读数压在孤立 MGM 尖峰下方,更接近已观测的官方区间。",
sourceRead:
"MGM 被视为主要结算参考。单个 27.1 C 点位在接受为新高前,需要与相邻官方读数比对。",
modelRead:
"ECMWF 在午后窗口比 DEB 融合更暖,但公开简报把官方观测置于纯模型移动之上。",
riskRead:
"主要风险是晚些时候官方更新或来源侧修正,导致公开快照后的认可高温发生变化。",
notFinancialAdvice:
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
distributionText:
"安卡拉 2026-06-24 公开天气市场简报:MGM 官方读数更支持 24.5 C 已观测高温,而非孤立 27.1 C 尖峰;DEB 仍低于异常点。非金融建议。",
primaryCtaLabel: "打开实时终端",
signals: [
{
label: "目前官方高温",
value: "24.5 C",
detail: "用于对比任何孤立更高点的官方来源值。",
},
{
label: "异常点待复核",
value: "27.1 C",
detail: "突然出现的单源值需要相邻时间验证。",
},
{
label: "DEB 公开读数",
value: "低于尖峰",
detail: "融合路径仍更接近已验证观测带。",
},
],
checkpoints: [
"检查疑似尖峰是否进入官方高温摘要。",
"在把单点视为结算相关之前,先比较相邻 MGM 观测。",
"市场关闭前查看付费终端的实时图表 patch 和来源新鲜度。",
],
},
"hong-kong:2026-06-24": {
cityName: "香港",
countryName: "香港",
title: "香港天气市场简报 - 2026年6月24日",
description:
"公开简报展示 PolyWeather 如何把 HKO/长洲/机场观测与 DEB、模型分歧放在一起解释最高温市场。",
market: "城市与机场最高温判断",
settlementSource: "HKO 官方网络",
dataFreshness:
"静态公开快照。HKO 和站点缓存刷新后,实时终端数值可能不同。",
debRead:
"到午后后段,实况观测与模型分歧趋于一致,DEB 支持较窄的高温窗口。",
sourceRead:
"解释机场单独移动前,应先把 HKO 网络观测作为需要校验的来源族。",
modelRead:
"模型分歧有限,因此来源新鲜度和站点选择比大尺度天气不确定性更重要。",
riskRead:
"主要风险是午后后段特定站点保温,或官方摘要出现较晚修订。",
notFinancialAdvice:
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
distributionText:
"香港 2026-06-24 公开天气市场简报:来源选择和 HKO 新鲜度比模型分歧更关键。非金融建议。",
primaryCtaLabel: "打开实时终端",
signals: [
{
label: "来源族",
value: "HKO",
detail: "先使用官方站上下文,再解释机场读数。",
},
{
label: "模型分歧",
value: "低",
detail: "晚间不确定性主要来自站点行为。",
},
{
label: "终端需要",
value: "新鲜度",
detail: "实时来源时间戳决定公开快照是否仍有用。",
},
],
checkpoints: [
"比较市场温度桶前先确认 HKO 站点时间戳。",
"把机场 METAR 观测与 HKO 网络读数分开解释。",
"如果公开快照已超过一个刷新周期,检查终端来源健康状态。",
],
},
"new-york:2026-06-24": {
cityName: "纽约",
countryName: "美国",
title: "纽约天气市场简报 - 2026年6月24日",
description:
"纽约温度市场公开简报,连接 METAR、NOAA 上下文、DEB 融合和日内尾段风险检查。",
market: "机场关联最高温判断",
settlementSource: "METAR 与 NOAA 站点上下文",
dataFreshness:
"静态公开快照。付费终端用户应检查当前 METAR 观测和官方摘要。",
debRead:
"DEB 用最新观测趋势校验偏暖模型指引,而不是直接追随原始模型高温。",
sourceRead:
"METAR 提供快速机场证据,NOAA 上下文帮助确认机场读数是否具代表性。",
modelRead:
"偏暖模型指引只有在和机场实况、云量/风场上下文校验后才有价值。",
riskRead:
"主要风险是日内尾段云层短暂打开,将机场观测推入更高温度桶。",
notFinancialAdvice:
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
distributionText:
"纽约 2026-06-24 公开天气市场简报:DEB 将偏暖模型指引与机场实时证据融合。非金融建议。",
primaryCtaLabel: "打开实时终端",
signals: [
{
label: "快速证据",
value: "METAR",
detail: "机场观测定义短周期读数。",
},
{
label: "复核",
value: "NOAA",
detail: "官方上下文帮助审计最终最高温解释。",
},
{
label: "DEB 立场",
value: "融合",
detail: "没有实况确认,不追随偏暖模型运行。",
},
],
checkpoints: [
"在每日高温窗口结束前观察最后两个 METAR 周期。",
"检查模型偏暖是否得到云量和风场观测支持。",
"最终解释结算前先使用官方来源上下文。",
],
},
};
const METHODOLOGY_TITLE_LOCALIZATIONS: Record<string, string> = {
deb: "DEB 预测方法",
"settlement-sources": "结算源优先级",
};
const SOURCE_TITLE_LOCALIZATIONS: Record<string, string> = {
ecmwf: "ECMWF 模型指引",
hko: "HKO 官方观测",
metar: "METAR 机场观测",
mgm: "MGM 天气来源",
noaa: "NOAA 天气上下文",
};
export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief {
if (locale === "en-US") return brief;
const localized = BRIEF_LOCALIZATIONS[`${brief.city}:${brief.date}`] || {};
return {
...brief,
...localized,
checkpoints: localized.checkpoints || brief.checkpoints,
methodologySlugs: localized.methodologySlugs || brief.methodologySlugs,
signals: localized.signals || brief.signals,
sourceSlugs: localized.sourceSlugs || brief.sourceSlugs,
};
}
export function localizeBriefs(locale: LandingLocale) {
return PUBLIC_BRIEFS.map((brief) => localizeBrief(brief, locale));
}
export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage {
if (locale === "en-US") return page;
return {
...page,
title: METHODOLOGY_TITLE_LOCALIZATIONS[page.slug] || page.title,
};
}
export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage {
if (locale === "en-US") return page;
return {
...page,
title: SOURCE_TITLE_LOCALIZATIONS[page.slug] || page.title,
};
}
export function briefPath(brief: PublicBrief) {
return `/briefs/${brief.city}/${brief.date}`;
}
export function methodologyPath(page: MethodologyPage) {
return `/methodology/${page.slug}`;
}
export function sourcePath(page: SourcePage) {
return `/sources/${page.slug}`;
}
export function getBrief(city: string, date: string) {
return PUBLIC_BRIEFS.find((brief) => brief.city === city && brief.date === date);
}
export function getMethodologyPage(slug: string) {
return METHODOLOGY_PAGES.find((page) => page.slug === slug);
}
export function getSourcePage(slug: string) {
return SOURCE_PAGES.find((page) => page.slug === slug);
}
export function absolutePublicUrl(pathname: string) {
return `${PUBLIC_CONTENT_BASE_URL}${pathname}`;
}
+5 -1
View File
@@ -17,7 +17,11 @@ type TrackableAnalyticsEvent =
| "paywall_feature_clicked"
| "paywall_viewed"
| "checkout_started"
| "checkout_succeeded";
| "checkout_succeeded"
| "brief_view"
| "brief_cta_click"
| "methodology_view"
| "social_outbound_click";
const CLIENT_ID_KEY = "polyweather:analytics:client-id";
const SESSION_ID_KEY = "polyweather:analytics:session-id";
+7
View File
@@ -34,6 +34,11 @@ export const opsApi = {
async funnel(days = 30) {
const raw = await opsFetch<{
events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
content_events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
content?: {
paths?: { name: string; count: number }[];
cities?: { name: string; count: number }[];
};
diagnostics?: Record<string, { total?: number; unique_actors?: number; by_reason?: { name: string; count: number }[] }>;
rates?: Record<string, number>;
traffic?: {
@@ -68,6 +73,8 @@ export const opsApi = {
return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev };
});
return {
content: raw?.content ?? {},
contentEvents: raw?.content_events ?? {},
diagnostics: raw?.diagnostics ?? {},
rates: raw?.rates,
steps,
+28 -13
View File
@@ -880,6 +880,7 @@ def update_daily_record(
shadow_probabilities=None,
calibration_summary=None,
hourly_error=None,
actual_is_final=True,
):
"""
保存/更新某城市某天的各个模型预报与最终实测值
@@ -986,7 +987,7 @@ def update_daily_record(
next_mu = round(mu, 2) if mu is not None else None
if (
old_actual == actual_high
old_actual == (actual_high if actual_is_final else old_actual)
and old_forecasts == merged_forecasts
and (deb_prediction is None or old_deb == deb_prediction)
and (mu is None or old_mu == next_mu)
@@ -1007,15 +1008,21 @@ def update_daily_record(
):
return
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
if old_actual is not None and actual_high is not None:
try:
actual_high = max(float(old_actual), float(actual_high))
except Exception:
pass
# Only final settlement truth may update actual_high. Intraday max_so_far is
# useful context, but treating it as settled truth poisons DEB training.
next_actual_high = old_actual
if actual_is_final:
next_actual_high = actual_high
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
if old_actual is not None and actual_high is not None:
try:
next_actual_high = max(float(old_actual), float(actual_high))
except Exception:
pass
existing["forecasts"] = merged_forecasts
existing["actual_high"] = actual_high
if actual_is_final or next_actual_high is not None:
existing["actual_high"] = next_actual_high
if deb_prediction is not None:
existing["deb_prediction"] = deb_prediction
if mu is not None:
@@ -1031,16 +1038,16 @@ def update_daily_record(
if next_hourly_error is not None:
existing["hourly_error"] = next_hourly_error
if actual_high is not None:
if actual_is_final and next_actual_high is not None:
try:
_persist_truth_record(
city_name,
date_str,
float(actual_high),
float(next_actual_high),
updated_by="runtime:update_daily_record",
reason="update_daily_record",
source_payload={
"actual_high": actual_high,
"actual_high": next_actual_high,
"deb_prediction": deb_prediction,
"mu": next_mu,
},
@@ -1161,8 +1168,12 @@ def calculate_dynamic_weight_components(
sorted_dates = sorted(city_data.keys(), reverse=True)
today_str = datetime.now().strftime("%Y-%m-%d")
available_days = sum(
1 for d in sorted_dates
if d != today_str and city_data[d].get("actual_high") is not None
1
for d in sorted_dates
if d != today_str
and city_data[d].get("actual_high") is not None
and isinstance(city_data[d].get("forecasts"), dict)
and any(v is not None for v in city_data[d].get("forecasts", {}).values())
)
# ── 改进3: 自适应 lookback — 数据多的城市用更多历史 ──
@@ -1187,6 +1198,7 @@ def calculate_dynamic_weight_components(
if actual is None:
continue
usable_day = False
for model in forecasts.keys():
if model in past_forecasts and past_forecasts[model] is not None:
try:
@@ -1194,6 +1206,7 @@ def calculate_dynamic_weight_components(
av = float(actual)
except (TypeError, ValueError):
continue
usable_day = True
# Track signed error for bias
model_biases[model] += (pv - av) # positive = model overpredicts
bias_samples[model] += 1
@@ -1208,6 +1221,8 @@ def calculate_dynamic_weight_components(
decay_weight = decay_factor ** days_used
errors[model].append((blended_error, decay_weight))
if not usable_day:
continue
days_used += 1
if days_used >= effective_lookback:
break
+1
View File
@@ -974,6 +974,7 @@ def analyze_weather_trend(
deb_prediction=_deb_to_save,
mu=mu,
probabilities=_prob_list,
actual_is_final=False,
)
except Exception:
pass
+43
View File
@@ -2672,6 +2672,12 @@ class DBManager:
"payment_start",
"payment_success",
]
content_event_names = [
"brief_view",
"brief_cta_click",
"methodology_view",
"social_outbound_click",
]
diagnostic_event_names = ["degraded_auth_profile"]
event_aliases = {
"landing_view": ("landing_view",),
@@ -2697,6 +2703,20 @@ class DBManager:
}
actor_sets: Dict[str, set[str]] = {name: set() for name in event_names}
user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
content_summary: Dict[str, Dict[str, Any]] = {
name: {
"total": 0,
"unique_users": 0,
"unique_actors": 0,
}
for name in content_event_names
}
content_actor_sets: Dict[str, set[str]] = {
name: set() for name in content_event_names
}
content_user_sets: Dict[str, set[str]] = {
name: set() for name in content_event_names
}
diagnostics: Dict[str, Dict[str, Any]] = {
name: {"total": 0, "unique_actors": 0, "by_reason": []}
for name in diagnostic_event_names
@@ -2711,6 +2731,8 @@ class DBManager:
country_counts: Counter = Counter()
device_counts: Counter = Counter()
landing_path_counts: Counter = Counter()
content_path_counts: Counter = Counter()
content_city_counts: Counter = Counter()
def _payload(row: Dict[str, Any]) -> Dict[str, Any]:
payload = row.get("payload")
@@ -2756,6 +2778,19 @@ class DBManager:
diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1
continue
if raw_event_type in content_summary:
content_summary[raw_event_type]["total"] += 1
user_id = str(row.get("user_id") or "").strip().lower()
if user_id:
content_user_sets[raw_event_type].add(user_id)
content_actor_sets[raw_event_type].add(_actor_key(row))
path = str(payload.get("path") or "/").strip()[:120]
content_path_counts[path or "/"] += 1
city = str(payload.get("city") or "").strip().lower()
if city:
content_city_counts[city[:80]] += 1
continue
event_type = alias_to_event.get(raw_event_type)
if not event_type:
continue
@@ -2778,6 +2813,9 @@ class DBManager:
for name in event_names:
summary[name]["unique_users"] = len(user_sets[name])
summary[name]["unique_actors"] = len(actor_sets[name])
for name in content_event_names:
content_summary[name]["unique_users"] = len(content_user_sets[name])
content_summary[name]["unique_actors"] = len(content_actor_sets[name])
for name in diagnostic_event_names:
diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name])
diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6)
@@ -2793,6 +2831,11 @@ class DBManager:
"window_days": safe_days,
"since": since_dt.isoformat(),
"events": summary,
"content_events": content_summary,
"content": {
"paths": _top(content_path_counts),
"cities": _top(content_city_counts),
},
"diagnostics": diagnostics,
"traffic": {
"referrers": _top(referrer_counts),
+19 -5
View File
@@ -2470,18 +2470,32 @@ class PaymentContractCheckoutService:
else:
self._require_user_wallet(user_id, from_addr)
validation_pending_reasons = {
"payment_tx_validation_failed",
"tx_not_mined",
}
try:
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
except Exception as exc:
raise PaymentCheckoutError(
400,
f"payment_tx_validation_failed: {exc}",
) from exc
if intent.payment_mode != "direct":
raise PaymentCheckoutError(
400,
f"payment_tx_validation_failed: {exc}",
) from exc
validation = {
"valid": False,
"reason": "payment_tx_validation_failed",
"detail": str(exc),
}
if not bool(validation.get("valid")):
reason = str(validation.get("reason") or "payment_tx_invalid").strip()
detail = str(validation.get("detail") or reason).strip()
message = reason if detail == reason else f"{reason}: {detail}"
raise PaymentCheckoutError(400, message)
is_pending_direct_validation = (
intent.payment_mode == "direct" and reason in validation_pending_reasons
)
if not is_pending_direct_validation:
raise PaymentCheckoutError(400, message)
now_iso = _to_iso(now)
self._rest(
+88
View File
@@ -0,0 +1,88 @@
from src.database.runtime_state import (
DailyRecordRepository,
RuntimeStateDB,
TrainingFeatureRecordRepository,
TruthRecordRepository,
)
def _wire_runtime_repos(monkeypatch, tmp_path):
from src.analysis import deb_algorithm
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
daily_repo = DailyRecordRepository(db)
truth_repo = TruthRecordRepository(db)
feature_repo = TrainingFeatureRecordRepository(db)
monkeypatch.setattr(deb_algorithm, "_daily_record_repo", daily_repo)
monkeypatch.setattr(deb_algorithm, "_truth_record_repo", truth_repo)
monkeypatch.setattr(deb_algorithm, "_training_feature_repo", feature_repo)
monkeypatch.setattr(deb_algorithm, "_history_cache", {})
monkeypatch.setattr(deb_algorithm, "_history_mtime", 0)
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "sqlite")
return db
def test_intraday_daily_record_does_not_persist_unfinal_actual_as_truth(monkeypatch, tmp_path):
from src.analysis.deb_algorithm import update_daily_record
db = _wire_runtime_repos(monkeypatch, tmp_path)
update_daily_record(
"ankara",
"2026-06-24",
{"ECMWF": 28.0, "GFS": 27.0},
24.0,
deb_prediction=27.5,
mu=27.2,
actual_is_final=False,
)
with db.connect() as conn:
daily = conn.execute(
"""
SELECT actual_high, deb_prediction, mu, payload_json
FROM daily_records_store
WHERE city = ? AND target_date = ?
""",
("ankara", "2026-06-24"),
).fetchone()
truth_count = conn.execute(
"""
SELECT count(*) AS n
FROM truth_records_store
WHERE city = ? AND target_date = ?
""",
("ankara", "2026-06-24"),
).fetchone()["n"]
assert daily is not None
assert daily["actual_high"] is None
assert daily["deb_prediction"] == 27.5
assert daily["mu"] == 27.2
assert truth_count == 0
def test_dynamic_weights_skip_actual_only_rows_when_selecting_recent_training_days(monkeypatch):
from src.analysis.deb_algorithm import calculate_dynamic_weight_components
history = {"ankara": {}}
for day in range(23, 15, -1):
history["ankara"][f"2026-06-{day:02d}"] = {"actual_high": 20.0}
history["ankara"]["2026-06-15"] = {
"actual_high": 20.0,
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
}
history["ankara"]["2026-06-14"] = {
"actual_high": 20.0,
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
}
monkeypatch.setattr("src.analysis.deb_algorithm.load_history", lambda _path: history)
components = calculate_dynamic_weight_components(
"ankara",
{"ECMWF": 20.0, "GFS": 30.0},
lookback_days=7,
)
assert components["weights"]["ECMWF"] > components["weights"]["GFS"]
assert components["prediction"] < 25.0
+21
View File
@@ -94,6 +94,13 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
1,
)[0]
warmer_block = compose.split(" polyweather_warmer:", 1)[1].split(
"\n polyweather_training_settlement:",
1,
)[0]
training_settlement_block = compose.split(
" polyweather_training_settlement:",
1,
)[1].split(
"\nx-polyweather-base:",
1,
)[0]
@@ -102,6 +109,7 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
assert "POLYWEATHER_SERVICE_ROLE: bot" in compose
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
assert "POLYWEATHER_SERVICE_ROLE: warmer" in warmer_block
assert "POLYWEATHER_SERVICE_ROLE: training_settlement" in training_settlement_block
assert "redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction" in compose
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
assert "POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}" in web_block
@@ -117,6 +125,18 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in web_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in warmer_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in training_settlement_block
assert "command: python -m web.training_settlement_worker" in training_settlement_block
assert (
"POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: "
"${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}"
in training_settlement_block
)
assert (
"POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS: "
"${POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS:-10}"
in training_settlement_block
)
assert "POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-3}" in web_block
assert "POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}" in web_block
@@ -287,6 +307,7 @@ def test_deploy_script_retries_compose_recreate_races():
assert 'compose_up_retry "backend services" -d --no-deps polyweather_web polyweather' in script
assert 'compose_up_retry "observation collector" -d --no-deps polyweather_collector' in script
assert 'compose_up_retry "cache warmer" -d --no-deps polyweather_warmer' in script
assert 'compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement' in script
assert 'compose_up_retry "frontend" -d --no-deps polyweather_frontend' in script
+70 -9
View File
@@ -416,10 +416,12 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
assert submitted["tx_hash"] == "0x" + "2" * 64
def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path):
def test_direct_submit_accepts_pending_validation_for_background_confirm(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "7" * 64
submitted = {}
transactions = []
monkeypatch.setattr(
service,
@@ -458,17 +460,76 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
return []
if method == "GET" and table == "payment_intents":
return []
raise AssertionError(f"submit must not mutate {table} before validation passes")
if method == "PATCH" and table == "payment_intents":
submitted.update(kwargs["payload"])
return [{"ok": True}]
if method == "POST" and table == "payment_transactions":
transactions.append(kwargs["payload"])
return [kwargs["payload"]]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
try:
service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
assert "tx_not_mined" in getattr(exc, "detail", "")
else:
raise AssertionError("expected tx_not_mined rejection")
result = service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
assert result["status"] == "submitted"
assert submitted["status"] == "submitted"
assert submitted["tx_hash"] == tx_hash
assert transactions[0]["status"] == "submitted"
def test_direct_submit_accepts_validation_exception_for_background_confirm(monkeypatch, tmp_path):
_setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService()
tx_hash = "0x" + "a" * 64
submitted = {}
monkeypatch.setattr(
service,
"get_intent",
lambda user_id, intent_id: service._serialize_intent(
{
"id": intent_id,
"plan_code": "pro_monthly",
"plan_id": 101,
"chain_id": 137,
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
"amount_units": "5000000",
"payment_mode": "direct",
"allowed_wallet": None,
"order_id_hex": "0x" + "1" * 64,
"status": "created",
"expires_at": "2099-01-01T00:00:00+00:00",
"tx_hash": None,
"metadata": {},
}
),
)
def fail_validation(*args, **kwargs):
raise RuntimeError("rpc timeout")
monkeypatch.setattr(service, "_validate_loaded_intent_tx", fail_validation)
def fake_rest(method, table, **kwargs):
if method == "GET" and table == "payment_transactions":
return []
if method == "GET" and table == "payment_intents":
return []
if method == "PATCH" and table == "payment_intents":
submitted.update(kwargs["payload"])
return [{"ok": True}]
if method == "POST" and table == "payment_transactions":
return [kwargs["payload"]]
raise AssertionError((method, table, kwargs))
monkeypatch.setattr(service, "_rest", fake_rest)
result = service.submit_intent_tx("user-1", "intent-direct-rpc-pending", tx_hash, "")
assert result["status"] == "submitted"
assert submitted["tx_hash"] == tx_hash
def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path):
+100
View File
@@ -0,0 +1,100 @@
import web.training_settlement_service as training_settlement_service
from web.training_settlement_service import run_training_settlement_cycle
def test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities():
calls = {"analysis": [], "reconcile": []}
def analysis_runner(city):
calls["analysis"].append(city)
return {"city": city, "deb": {"prediction": 31.2}}
def actual_reconciler(city, *, lookback_days):
calls["reconcile"].append((city, lookback_days))
return {"ok": True, "updated": 1}
result = run_training_settlement_cycle(
city_registry={
"shanghai": {"icao": "ZSSS", "settlement_source": "metar"},
"legacy": {"settlement_source": "wunderground"},
},
analysis_runner=analysis_runner,
actual_reconciler=actual_reconciler,
lookback_days=9,
)
assert result["ok"] is True
assert result["processed"] == 1
assert result["failed"] == 0
assert result["unsupported"] == 1
assert calls["analysis"] == ["shanghai"]
assert calls["reconcile"] == [("shanghai", 9)]
def test_training_settlement_cycle_continues_after_city_failure():
calls = []
def analysis_runner(city):
calls.append(city)
if city == "shanghai":
raise RuntimeError("analysis unavailable")
return {"city": city}
result = run_training_settlement_cycle(
city_registry={
"shanghai": {"icao": "ZSSS", "settlement_source": "metar"},
"tokyo": {"icao": "RJTT", "settlement_source": "metar"},
},
analysis_runner=analysis_runner,
actual_reconciler=lambda city, *, lookback_days: {"ok": True, "updated": 0},
lookback_days=3,
)
assert result["ok"] is False
assert result["processed"] == 1
assert result["failed"] == 1
assert calls == ["shanghai", "tokyo"]
assert result["items"][0]["ok"] is False
assert result["items"][1]["ok"] is True
def test_training_settlement_cycle_skips_reconcile_for_non_reconcile_sources():
calls = {"analysis": [], "reconcile": []}
result = run_training_settlement_cycle(
city_registry={
"taipei": {"icao": "RCSS", "settlement_source": "cwa"},
},
analysis_runner=lambda city: calls["analysis"].append(city) or {"city": city},
actual_reconciler=lambda city, *, lookback_days: calls["reconcile"].append(city)
or {"ok": True},
lookback_days=3,
)
assert result["ok"] is True
assert result["processed"] == 1
assert calls["analysis"] == ["taipei"]
assert calls["reconcile"] == []
assert result["items"][0]["reconcile"]["reason"] == "unsupported_reconcile_source"
def test_default_actual_reconciler_bootstraps_missing_history(monkeypatch):
calls = []
def bootstrapper(city, *, lookback_days):
calls.append((city, lookback_days))
return {"ok": True, "seeded": 8, "updated": 8}
monkeypatch.setattr(
training_settlement_service,
"bootstrap_recent_daily_history_if_missing",
bootstrapper,
)
result = training_settlement_service._default_actual_reconciler(
"shanghai",
lookback_days=10,
)
assert result == {"ok": True, "seeded": 8, "updated": 8}
assert calls == [("shanghai", 10)]
+145 -1
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient
from starlette.requests import Request
@@ -9,13 +10,16 @@ import web.services.auth_api as auth_api
from web.app import app
import web.routes as routes
import web.services.ops_api as ops_api
import web.diagnostics.health as diagnostics_health
import web.scan_terminal_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service
import web.services.system_api as system_api
import web.services.city_api as city_api
import web.services.city_runtime as city_runtime
from web.services.observation_freshness import build_observation_freshness
from web.scan_terminal_cache import scan_terminal_cache_key
from src.database.runtime_state import TruthRecordRepository
from src.database.runtime_state import RuntimeStateDB, TruthRecordRepository
from src.utils.metrics import export_prometheus_metrics
client = TestClient(app)
@@ -83,6 +87,8 @@ def test_system_status_returns_summary_shape_for_ops_admin(monkeypatch):
assert 'sse_connections' in payload['realtime']
assert 'truth_records' in payload['training_data']
assert 'training_features' in payload['training_data']
assert 'stale_days' in payload['training_data']['truth_records']
assert 'stale_days' in payload['training_data']['training_features']
assert 'city_coverage' in payload['training_data']
assert 'model_city_coverage' in payload['training_data']
assert 'metar_entries' in payload['cache']
@@ -121,6 +127,85 @@ def test_metrics_endpoint_returns_prometheus_payload_for_ops_admin(monkeypatch):
assert 'polyweather_http_requests_total' in response.text
def test_training_data_summary_reports_stale_days(tmp_path):
db = RuntimeStateDB(str(tmp_path / "training.db"))
yesterday = (datetime.now(timezone.utc).date() - timedelta(days=1)).strftime("%Y-%m-%d")
stale_day = (datetime.now(timezone.utc).date() - timedelta(days=5)).strftime("%Y-%m-%d")
with db.connect() as conn:
conn.execute(
"""
INSERT INTO truth_records_store (
city, target_date, actual_high, settlement_source, updated_at, is_final
) VALUES ('shanghai', ?, 31.2, 'metar', 1, 1)
""",
(yesterday,),
)
conn.execute(
"""
INSERT INTO training_feature_records_store (
city, target_date, updated_at, payload_json
) VALUES ('shanghai', ?, 1, '{}')
""",
(stale_day,),
)
conn.commit()
payload = diagnostics_health._training_data_summary(
SimpleNamespace(db_path=db.db_path),
{"shanghai": {"name": "Shanghai", "settlement_source": "metar", "icao": "ZSSS"}},
)
assert payload["truth_records"]["stale_days"] == 1
assert payload["training_features"]["stale_days"] == 5
assert payload["stale"] is True
def test_prometheus_exports_training_data_stale_metrics(monkeypatch, tmp_path):
db = RuntimeStateDB(str(tmp_path / "training-metrics.db"))
stale_day = (datetime.now(timezone.utc).date() - timedelta(days=4)).strftime("%Y-%m-%d")
with db.connect() as conn:
conn.execute(
"""
INSERT INTO daily_records_store (
city, target_date, actual_high, deb_prediction, mu, updated_at, payload_json
) VALUES ('shanghai', ?, 31.2, 31.0, 31.1, 1, '{}')
""",
(stale_day,),
)
conn.execute(
"""
INSERT INTO truth_records_store (
city, target_date, actual_high, settlement_source, updated_at, is_final
) VALUES ('shanghai', ?, 31.2, 'metar', 1, 1)
""",
(stale_day,),
)
conn.execute(
"""
INSERT INTO training_feature_records_store (
city, target_date, updated_at, payload_json
) VALUES ('shanghai', ?, 1, '{}')
""",
(stale_day,),
)
conn.commit()
fake_db = SimpleNamespace(
db_path=db.db_path,
list_payment_audit_events=lambda **_kwargs: [],
list_refund_cases=lambda **_kwargs: [],
)
monkeypatch.setattr(system_api, "DBManager", lambda: fake_db)
system_api._refresh_operational_metrics()
metrics = export_prometheus_metrics()
assert "polyweather_daily_records_stale_days 4" in metrics
assert "polyweather_truth_records_stale_days 4" in metrics
assert "polyweather_training_features_stale_days 4" in metrics
assert "polyweather_training_data_stale 1" in metrics
def test_system_cache_status_requires_ops_admin(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
@@ -202,6 +287,10 @@ def test_standard_growth_funnel_events_are_trackable():
"payment_start",
"payment_success",
"degraded_auth_profile",
"brief_view",
"brief_cta_click",
"methodology_view",
"social_outbound_click",
}.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS)
@@ -287,6 +376,61 @@ def test_growth_funnel_summarizes_traffic_sources(monkeypatch):
assert {"name": "mobile", "count": 1} in summary["traffic"]["devices"]
def test_growth_funnel_summarizes_public_content_events(monkeypatch):
from src.database.db_manager import DBManager
rows = [
{
"id": 1,
"event_type": "brief_view",
"user_id": "",
"client_id": "c1",
"session_id": "s1",
"payload": {"path": "/briefs/ankara/2026-06-24", "city": "ankara"},
},
{
"id": 2,
"event_type": "brief_cta_click",
"user_id": "",
"client_id": "c1",
"session_id": "s1",
"payload": {"path": "/briefs/ankara/2026-06-24", "cta": "terminal"},
},
{
"id": 3,
"event_type": "methodology_view",
"user_id": "",
"client_id": "c2",
"session_id": "s2",
"payload": {"path": "/methodology/deb", "slug": "deb"},
},
{
"id": 4,
"event_type": "social_outbound_click",
"user_id": "",
"client_id": "c3",
"session_id": "s3",
"payload": {"path": "/briefs/ankara/2026-06-24", "destination": "x_intent"},
},
]
monkeypatch.setattr(
DBManager,
"list_app_analytics_events",
lambda self, limit=20000, since_iso=None: rows,
)
summary = DBManager().get_app_analytics_funnel_summary(days=7)
assert summary["content_events"]["brief_view"]["total"] == 1
assert summary["content_events"]["brief_cta_click"]["unique_actors"] == 1
assert summary["content_events"]["methodology_view"]["total"] == 1
assert summary["content_events"]["social_outbound_click"]["total"] == 1
assert summary["content"]["paths"][0] == {
"name": "/briefs/ankara/2026-06-24",
"count": 3,
}
def test_ops_source_health_flags_expected_official_sources(monkeypatch):
class FakeCache:
def get_city_cache(self, kind, city):
+37 -1
View File
@@ -133,15 +133,28 @@ def _table_date_summary(conn, table_name: str) -> Dict[str, Any]:
except Exception as exc:
return {"ok": False, "error": str(exc), "row_count": 0, "cities_count": 0}
max_date = row["max_date"]
return {
"ok": True,
"row_count": int(row["row_count"] or 0),
"cities_count": int(row["cities_count"] or 0),
"min_date": row["min_date"],
"max_date": row["max_date"],
"max_date": max_date,
"stale_days": _target_date_stale_days(max_date),
}
def _target_date_stale_days(target_date: Optional[str]) -> Optional[int]:
raw = str(target_date or "").strip()
if not raw:
return None
try:
parsed = datetime.strptime(raw, "%Y-%m-%d").date()
except Exception:
return None
return max(0, (datetime.now(timezone.utc).date() - parsed).days)
def _truth_source_counts(conn) -> Dict[str, int]:
try:
rows = conn.execute(
@@ -293,11 +306,17 @@ def _training_data_summary(account_db, city_registry) -> Dict[str, Any]:
import sqlite3
db_path = account_db.db_path
daily_records = {"ok": False, "row_count": 0, "cities_count": 0}
truth_records = {"ok": False, "row_count": 0, "cities_count": 0}
truth_revisions = {"ok": False, "row_count": 0}
training_features = {"ok": False, "row_count": 0, "cities_count": 0}
stale_threshold_days = max(
0,
int(os.getenv("POLYWEATHER_TRAINING_DATA_STALE_WARN_DAYS", "2") or "2"),
)
try:
with connect_sqlite(db_path, row_factory=sqlite3.Row) as conn:
daily_records = _table_date_summary(conn, "daily_records_store")
truth_records = _table_date_summary(conn, "truth_records_store")
if truth_records.get("ok"):
truth_records["source_counts"] = _truth_source_counts(conn)
@@ -311,19 +330,36 @@ def _training_data_summary(account_db, city_registry) -> Dict[str, Any]:
"db_path": db_path,
"db_ok": False,
"error": str(exc),
"daily_records": daily_records,
"truth_records": truth_records,
"truth_revisions": truth_revisions,
"training_features": training_features,
"stale": True,
"stale_threshold_days": stale_threshold_days,
"city_coverage": {},
"model_city_coverage": {},
}
stale_days = [
value
for value in (
daily_records.get("stale_days"),
truth_records.get("stale_days"),
training_features.get("stale_days"),
)
if value is not None
]
return {
"db_path": db_path,
"db_ok": True,
"daily_records": daily_records,
"truth_records": truth_records,
"truth_revisions": truth_revisions,
"training_features": training_features,
"stale": any(int(value) > stale_threshold_days for value in stale_days)
if stale_days
else True,
"stale_threshold_days": stale_threshold_days,
"city_coverage": city_coverage,
"model_city_coverage": _model_city_coverage_summary(
city_coverage.get("entries") or [],
+4
View File
@@ -82,6 +82,10 @@ TRACKABLE_ANALYTICS_EVENTS = {
"paywall_viewed",
"checkout_started",
"checkout_succeeded",
"brief_view",
"brief_cta_click",
"methodology_view",
"social_outbound_click",
}
DEFAULT_STATUS_CITIES = [
+50
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import time
import os
from collections import Counter
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import BackgroundTasks, Request
@@ -13,6 +14,7 @@ from fastapi.responses import PlainTextResponse
from loguru import logger
from src.database.db_manager import DBManager
from src.database.sqlite_connection import connect_sqlite
from src.utils.metrics import export_prometheus_metrics, gauge_set
from web.core import build_health_payload, build_system_status_payload
import web.routes as legacy_routes
@@ -211,3 +213,51 @@ def _refresh_operational_metrics() -> None:
gauge_set("polyweather_sqlite_db_size_bytes", os.path.getsize(db_path))
except OSError:
pass
_refresh_training_data_metrics(db_path)
def _target_date_stale_days(target_date: Optional[str]) -> Optional[int]:
raw = str(target_date or "").strip()
if not raw:
return None
try:
parsed = datetime.strptime(raw, "%Y-%m-%d").date()
except Exception:
return None
return max(0, (datetime.now(timezone.utc).date() - parsed).days)
def _max_target_date(db_path: str, table_name: str) -> Optional[str]:
with connect_sqlite(db_path) as conn:
row = conn.execute(f"SELECT MAX(target_date) AS max_date FROM {table_name}").fetchone()
if not row:
return None
return row[0]
def _refresh_training_data_metrics(db_path: str) -> None:
stale_threshold_days = max(
0,
int(os.getenv("POLYWEATHER_TRAINING_DATA_STALE_WARN_DAYS", "2") or "2"),
)
table_metrics = {
"daily_records_store": "polyweather_daily_records_stale_days",
"truth_records_store": "polyweather_truth_records_stale_days",
"training_feature_records_store": "polyweather_training_features_stale_days",
}
max_stale_days: Optional[int] = None
for table_name, metric_name in table_metrics.items():
try:
stale_days = _target_date_stale_days(_max_target_date(db_path, table_name))
except Exception:
stale_days = None
if stale_days is None:
gauge_set(metric_name, -1)
max_stale_days = max(max_stale_days or 0, stale_threshold_days + 1)
continue
gauge_set(metric_name, stale_days)
max_stale_days = max(max_stale_days or 0, stale_days)
gauge_set(
"polyweather_training_data_stale",
1 if max_stale_days is None or max_stale_days > stale_threshold_days else 0,
)
+142
View File
@@ -0,0 +1,142 @@
"""Low-frequency DEB training settlement maintenance."""
from __future__ import annotations
from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Sequence
from loguru import logger
from src.analysis.deb_algorithm import bootstrap_recent_daily_history_if_missing
from src.data_collection.city_registry import CITY_REGISTRY
AnalysisRunner = Callable[[str], Mapping[str, Any]]
ActualReconciler = Callable[..., Mapping[str, Any]]
UNSUPPORTED_SETTLEMENT_SOURCES = {"wunderground"}
RECONCILE_SETTLEMENT_SOURCES = {"metar", "hko", "noaa"}
def _normalize_city(city: str) -> str:
return str(city or "").strip().lower().replace("-", " ")
def _selected_city_names(
city_registry: Mapping[str, Mapping[str, Any]],
cities: Optional[Iterable[str]],
) -> Sequence[str]:
selected = {_normalize_city(city) for city in (cities or []) if _normalize_city(city)}
names = []
for city in sorted(city_registry.keys()):
normalized = _normalize_city(city)
if selected and normalized not in selected:
continue
names.append(normalized)
return tuple(names)
def _is_supported_training_city(city_meta: Mapping[str, Any]) -> bool:
source = str(city_meta.get("settlement_source") or "metar").strip().lower()
if source in UNSUPPORTED_SETTLEMENT_SOURCES:
return False
return bool(
str(city_meta.get("icao") or "").strip()
or str(city_meta.get("settlement_station_code") or "").strip()
or source in {"hko", "cwa", "ims", "ncm", "aeroweb"}
)
def _can_reconcile_actual_history(city_meta: Mapping[str, Any]) -> bool:
source = str(city_meta.get("settlement_source") or "metar").strip().lower()
return source in RECONCILE_SETTLEMENT_SOURCES
def _default_analysis_runner(city: str) -> Mapping[str, Any]:
from web.analysis_service import _analyze
return _analyze(city, force_refresh=False, detail_mode="panel")
def _default_actual_reconciler(city: str, *, lookback_days: int) -> Mapping[str, Any]:
return bootstrap_recent_daily_history_if_missing(city, lookback_days=lookback_days)
def run_training_settlement_cycle(
*,
city_registry: Optional[Mapping[str, Mapping[str, Any]]] = None,
cities: Optional[Iterable[str]] = None,
analysis_runner: Optional[AnalysisRunner] = None,
actual_reconciler: Optional[ActualReconciler] = None,
lookback_days: int = 10,
) -> Dict[str, Any]:
registry = city_registry or CITY_REGISTRY
run_analysis = analysis_runner or _default_analysis_runner
reconcile_actual = actual_reconciler or _default_actual_reconciler
safe_lookback = max(1, int(lookback_days or 1))
processed = 0
failed = 0
unsupported = 0
items = []
for city in _selected_city_names(registry, cities):
meta = registry.get(city) or {}
if not _is_supported_training_city(meta):
unsupported += 1
items.append(
{
"city": city,
"ok": True,
"status": "unsupported",
"reason": "unsupported_settlement_source",
}
)
continue
try:
analysis_payload = run_analysis(city)
if _can_reconcile_actual_history(meta):
reconcile_payload = reconcile_actual(city, lookback_days=safe_lookback)
else:
reconcile_payload = {
"ok": True,
"updated": 0,
"reason": "unsupported_reconcile_source",
"source": str(meta.get("settlement_source") or "").strip().lower(),
}
reconcile_ok = bool(reconcile_payload.get("ok", True))
if reconcile_ok:
processed += 1
else:
failed += 1
items.append(
{
"city": city,
"ok": reconcile_ok,
"status": "processed" if reconcile_ok else "failed",
"analysis_status": str(
(analysis_payload or {}).get("status") or "ok"
),
"reconcile": dict(reconcile_payload or {}),
}
)
except Exception as exc:
failed += 1
logger.warning("training settlement city failed city={}: {}", city, exc)
items.append(
{
"city": city,
"ok": False,
"status": "failed",
"error": str(exc),
}
)
return {
"ok": failed == 0,
"processed": processed,
"failed": failed,
"unsupported": unsupported,
"lookback_days": safe_lookback,
"items": items,
}
+103
View File
@@ -0,0 +1,103 @@
"""Standalone low-frequency DEB training settlement worker."""
from __future__ import annotations
import argparse
import json
import os
import signal
import threading
import time
from types import FrameType
from typing import Optional
from loguru import logger
from web.training_settlement_service import run_training_settlement_cycle
_STOP_EVENT = threading.Event()
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def _handle_stop_signal(signum: int, _frame: Optional[FrameType]) -> None:
logger.info("training settlement worker stopping signal={}", signum)
_STOP_EVENT.set()
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run low-frequency DEB training settlement maintenance."
)
parser.add_argument("--once", action="store_true", help="Run one cycle and exit.")
parser.add_argument(
"--interval-sec",
type=int,
default=_env_int("POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC", 21600),
)
parser.add_argument(
"--initial-delay-sec",
type=int,
default=_env_int("POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC", 60),
)
parser.add_argument(
"--lookback-days",
type=int,
default=_env_int("POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS", 10),
)
parser.add_argument("--cities", nargs="*", default=None)
return parser.parse_args()
def _run_once(*, lookback_days: int, cities: Optional[list[str]]) -> dict:
result = run_training_settlement_cycle(
cities=cities,
lookback_days=lookback_days,
)
logger.info("training settlement result={}", json.dumps(result, ensure_ascii=False))
return result
def main() -> None:
signal.signal(signal.SIGINT, _handle_stop_signal)
signal.signal(signal.SIGTERM, _handle_stop_signal)
args = _parse_args()
if args.once:
result = _run_once(lookback_days=args.lookback_days, cities=args.cities)
print(json.dumps(result, ensure_ascii=False))
return
interval_sec = max(300, int(args.interval_sec or 21600))
initial_delay_sec = max(0, int(args.initial_delay_sec or 0))
logger.info(
"training settlement worker started interval={}s lookback_days={}",
interval_sec,
args.lookback_days,
)
if initial_delay_sec and _STOP_EVENT.wait(initial_delay_sec):
return
while not _STOP_EVENT.is_set():
started = time.time()
try:
_run_once(lookback_days=args.lookback_days, cities=args.cities)
except Exception as exc:
logger.exception("training settlement cycle failed: {}", exc)
elapsed = time.time() - started
wait_for = max(5.0, interval_sec - elapsed)
if _STOP_EVENT.wait(wait_for):
break
if __name__ == "__main__":
main()