Compare commits

...

7 Commits

Author SHA1 Message Date
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
38 changed files with 2716 additions and 27 deletions
+3
View File
@@ -350,6 +350,9 @@ compose_up_retry "observation collector" -d --no-deps polyweather_collector
echo "Updating cache warmer..." echo "Updating cache warmer..."
compose_up_retry "cache warmer" -d --no-deps polyweather_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..." echo "Updating frontend..."
compose_up_retry "frontend" -d --no-deps polyweather_frontend compose_up_retry "frontend" -d --no-deps polyweather_frontend
+43
View File
@@ -253,6 +253,49 @@ services:
volumes: volumes:
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather - ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data - ${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: x-polyweather-base:
env_file: *id001 env_file: *id001
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest} 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.
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
import {
PUBLIC_BRIEFS,
absolutePublicUrl,
briefPath,
getBrief,
} from "@/content/public-content";
type BriefPageParams = {
city: string;
date: string;
};
export function generateStaticParams() {
return PUBLIC_BRIEFS.map((brief) => ({
city: brief.city,
date: brief.date,
}));
}
export async function generateMetadata({
params,
}: {
params: Promise<BriefPageParams>;
}): Promise<Metadata> {
const { city, date } = await params;
const brief = getBrief(city, date);
if (!brief) {
return {
title: "Brief not found",
};
}
const pathname = briefPath(brief);
return {
title: brief.title,
description: brief.description,
alternates: {
canonical: pathname,
},
openGraph: {
type: "article",
title: brief.title,
description: brief.description,
url: pathname,
publishedTime: brief.publishedAt,
modifiedTime: brief.updatedAt,
},
twitter: {
card: "summary",
title: brief.title,
description: brief.description,
},
};
}
export default async function BriefDetailPage({
params,
}: {
params: Promise<BriefPageParams>;
}) {
const { city, date } = await params;
const brief = getBrief(city, date);
if (!brief) {
notFound();
}
const pathname = briefPath(brief);
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "Article",
headline: brief.title,
description: brief.description,
datePublished: brief.publishedAt,
dateModified: brief.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
publisher: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
about: [
brief.cityName,
brief.market,
brief.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: brief.cityName,
item: absolutePublicUrl(pathname),
},
],
},
];
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<BriefDetailPageView brief={brief} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Weather Market Brief",
description:
"Public PolyWeather market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
alternates: {
canonical: "/briefs",
},
openGraph: {
title: "Weather Market Brief | PolyWeather",
description:
"Public market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
url: "/briefs",
},
};
export default function BriefsPage() {
return <BriefsIndexPageView />;
}
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google"; import { Inter, JetBrains_Mono } from "next/font/google";
import { RegisterSW } from "@/components/dashboard/RegisterSW"; import { RegisterSW } from "@/components/dashboard/RegisterSW";
import { MicrosoftClarity } from "@/components/observability/MicrosoftClarity";
import "./globals.css"; import "./globals.css";
const inter = Inter({ const inter = Inter({
@@ -89,6 +90,7 @@ export default function RootLayout({
</a> </a>
<main id="main-content">{children}</main> <main id="main-content">{children}</main>
<RegisterSW /> <RegisterSW />
<MicrosoftClarity />
</body> </body>
</html> </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 type { MetadataRoute } from "next";
import {
METHODOLOGY_PAGES,
PUBLIC_BRIEFS,
SOURCE_PAGES,
} from "@/content/public-content";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://polyweather.top"; const baseUrl = "https://polyweather.top";
const now = new Date();
return [ return [
{ {
url: baseUrl, url: baseUrl,
lastModified: new Date(), lastModified: now,
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1.0, priority: 1.0,
}, },
{ {
url: `${baseUrl}/auth/login`, url: `${baseUrl}/auth/login`,
lastModified: new Date(), lastModified: now,
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.6, 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"), paymentFlowSource.includes("/validate"),
"manual payment flow must validate tx hashes with the backend before submission", "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( assert(
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") && paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") < paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
@@ -826,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const lowerRaw = raw.toLowerCase(); const lowerRaw = raw.toLowerCase();
const maybePending = const maybePending =
confirmRes.status === 408 || 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"))); (confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
if (maybePending) { if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`); 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", "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( const guangzhouRunwayWithBadMadisChart = buildFullDayChartData(
{ {
city: "guangzhou", city: "guangzhou",
@@ -901,6 +901,16 @@ function canonicalAirportPrimarySourceLabel(hourly: ChartRenderState) {
return ""; 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( function airportCodeForSeriesLabel(
hourly: ChartRenderState, hourly: ChartRenderState,
row?: ScanOpportunityRow | null, row?: ScanOpportunityRow | null,
@@ -943,12 +953,15 @@ function airportPrimarySeriesLabel(
const cityKey = normalizeCityKey(row?.city); const cityKey = normalizeCityKey(row?.city);
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly); const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
if (canonicalLabel === "MGM") return canonicalLabel; 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")) { if ((cityKey === "ankara" || cityKey === "istanbul") && (!canonicalLabel || canonicalLabel === "NOAA MADIS")) {
return "MGM"; return "MGM";
} }
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim(); const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel; if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel;
const stationCode = airportCodeForSeriesLabel(hourly, row);
const isUsAirport = isUsAirportCode(stationCode); const isUsAirport = isUsAirportCode(stationCode);
if (!isUsAirport) { if (!isUsAirport) {
if (canonicalLabel && canonicalLabel !== "NOAA MADIS") return canonicalLabel; 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( function mergeAirportCondition(
base: AirportCurrentConditions | null | undefined, base: AirportCurrentConditions | null | undefined,
live: AirportCurrentConditions | null | undefined, live: AirportCurrentConditions | null | undefined,
@@ -1338,6 +1362,19 @@ function mergeAirportCondition(
return merged; 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>) { function runwayHistoryPointKey(point: Record<string, unknown>) {
const time = String( const time = String(
point.timestamp ?? point.timestamp ??
@@ -1677,6 +1714,10 @@ function mergeHourlyWithLiveObservations(
runway_plate_history: runwayPlateHistory, runway_plate_history: runwayPlateHistory,
} as AmosData } as AmosData
: detailSource.amos; : detailSource.amos;
const useLiveAirportPrimary =
airportPrimaryObservationSourceChanged(base, live) &&
Array.isArray(live.airportPrimaryTodayObs) &&
live.airportPrimaryTodayObs.length > 0;
return { return {
...detailSource, ...detailSource,
localDate, localDate,
@@ -1696,16 +1737,22 @@ function mergeHourlyWithLiveObservations(
: forecastFallback.probabilities || null, : forecastFallback.probabilities || null,
runwayPlateHistory, runwayPlateHistory,
amos, amos,
airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate), airportCurrent: useLiveAirportPrimary
airportPrimary: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate), ? 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, settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined,
settlementStationCode: detailSource.settlementStationCode || forecastFallback.settlementStationCode || row?.metar_context?.station || null, settlementStationCode: detailSource.settlementStationCode || forecastFallback.settlementStationCode || row?.metar_context?.station || null,
settlementStationLabel: detailSource.settlementStationLabel || forecastFallback.settlementStationLabel || null, settlementStationLabel: detailSource.settlementStationLabel || forecastFallback.settlementStationLabel || null,
metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined, metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined,
airportPrimaryTodayObs: mergeRawObservationPoints( airportPrimaryTodayObs: useLiveAirportPrimary
base.airportPrimaryTodayObs, ? live.airportPrimaryTodayObs
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"> <Link href="/docs/chart-guide" className="hover:text-slate-950">
{isEn ? "Guide" : "读图"} {isEn ? "Guide" : "读图"}
</Link> </Link>
<Link href="/briefs" className="hover:text-slate-950">
Weather Market Brief
</Link>
<a href="#pricing" className="hover:text-slate-950"> <a href="#pricing" className="hover:text-slate-950">
{isEn ? "Pricing" : "定价"} {isEn ? "Pricing" : "定价"}
</a> </a>
@@ -104,6 +104,8 @@ export function runTests() {
"landing supported cities must render names and station codes from the generated city groups", "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("#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("Weather Market Brief"), "landing page must label the public brief entry clearly");
assert(source.includes('id="contact"'), "landing page must include a contact section"); 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("yhrsc30@gmail.com"), "landing page must show the operator contact email");
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable"); 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( assert(
opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') && 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("diagnostics?:") &&
opsApi.includes("traffic?:") && opsApi.includes("traffic?:") &&
opsApi.includes("uniqueActors"), 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( assert(
analyticsPage.includes("落地页访问") && analyticsPage.includes("落地页访问") &&
analyticsPage.includes("进入终端") && analyticsPage.includes("进入终端") &&
analyticsPage.includes("注册成功") && analyticsPage.includes("注册成功") &&
analyticsPage.includes("鉴权降级") && analyticsPage.includes("鉴权降级") &&
analyticsPage.includes("公开内容获客") &&
analyticsPage.includes("Brief 浏览") &&
analyticsPage.includes("briefCtaClick?.total") &&
analyticsPage.includes("来源与设备") && analyticsPage.includes("来源与设备") &&
analyticsPage.includes("paymentSuccess?.count") && analyticsPage.includes("paymentSuccess?.count") &&
!analyticsPage.includes("总注册") && !analyticsPage.includes("总注册") &&
@@ -28,10 +28,17 @@ type AuthDiagnostic = {
unique_actors?: number; unique_actors?: number;
by_reason?: TopItem[]; by_reason?: TopItem[];
}; };
type ContentEventSummary = {
total?: number;
unique_users?: number;
unique_actors?: number;
};
export function AnalyticsPageClient() { export function AnalyticsPageClient() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [funnel, setFunnel] = useState<FunnelStep[]>([]); 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 [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
const [rates, setRates] = useState<Record<string, number> | null>(null); const [rates, setRates] = useState<Record<string, number> | null>(null);
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({}); const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
@@ -42,6 +49,8 @@ export function AnalyticsPageClient() {
try { try {
const data = await opsApi.funnel(days); const data = await opsApi.funnel(days);
setFunnel(data.steps); setFunnel(data.steps);
setContentEvents(data.contentEvents ?? {});
setContent(data.content ?? {});
setDiagnostics(data.diagnostics ?? {}); setDiagnostics(data.diagnostics ?? {});
setRates(data.rates ?? null); setRates(data.rates ?? null);
setTraffic(data.traffic ?? {}); setTraffic(data.traffic ?? {});
@@ -65,6 +74,10 @@ export function AnalyticsPageClient() {
const trialCreated = stepByKey.trial_created; const trialCreated = stepByKey.trial_created;
const paymentStart = stepByKey.payment_start; const paymentStart = stepByKey.payment_start;
const paymentSuccess = stepByKey.payment_success; 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 = const overallRate =
landingView?.uniqueActors && paymentSuccess?.uniqueActors landingView?.uniqueActors && paymentSuccess?.uniqueActors
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1) ? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
@@ -141,6 +154,56 @@ export function AnalyticsPageClient() {
</div> </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)]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
<Card> <Card>
<CardHeader><CardTitle></CardTitle></CardHeader> <CardHeader><CardTitle></CardTitle></CardHeader>
@@ -243,3 +306,21 @@ export function AnalyticsPageClient() {
</div> </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,467 @@
import Link from "next/link";
import {
METHODOLOGY_PAGES,
PUBLIC_BRIEFS,
SOURCE_PAGES,
absolutePublicUrl,
briefPath,
methodologyPath,
sourcePath,
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() {
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>
<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">
Briefs
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
Methodology
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
Sources
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
Docs
</Link>
</nav>
</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({ slugs }: { 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;
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(source)}
key={slug}
>
{source.title}
</Link>
);
})}
</div>
);
}
function MethodologyLinks({ slugs }: { 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;
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(page)}
key={slug}
>
{page.title}
</Link>
);
})}
</div>
);
}
function BriefCard({ brief }: { brief: PublicBrief }) {
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)}>
Read brief
</Link>
<SourceLinks slugs={brief.sourceSlugs.slice(0, 2)} />
</div>
</article>
);
}
export function BriefsIndexPageView() {
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro
description="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."
eyebrow="Weather Market Brief"
title="Public market briefs for temperature judgment"
/>
<div className={contentWrap}>
<section className="grid gap-4">
{PUBLIC_BRIEFS.map((brief) => (
<BriefCard brief={brief} key={`${brief.city}-${brief.date}`} />
))}
</section>
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>Methodology</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
How the public read is produced
</h2>
<p className={`${bodyText} mt-3`}>
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.
</p>
<div className="mt-4">
<MethodologyLinks slugs={["deb", "settlement-sources"]} />
</div>
</div>
<div>
<p className={sectionTitle}>Source notes</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
Official-source context
</h2>
<p className={`${bodyText} mt-3`}>
Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.
</p>
<div className="mt-4">
<SourceLinks slugs={["mgm", "metar", "hko", "noaa"]} />
</div>
</div>
</section>
</div>
</div>
);
}
export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
return (
<div className={pageShell}>
<PublicContentAnalytics
eventType="brief_view"
onceKey={`brief:${brief.city}:${brief.date}`}
payload={{ city: brief.city, date: brief.date, source: brief.settlementSource }}
/>
<PublicHeader />
<PageIntro
description={brief.description}
eyebrow={`${brief.cityName}, ${brief.countryName} / ${brief.date}`}
title={brief.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">
{brief.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="DEB read" body={brief.debRead} />
<BriefSection title="Settlement-source read" body={brief.sourceRead} />
<BriefSection title="Model context" body={brief.modelRead} />
<BriefSection title="Risk notes" body={brief.riskRead} />
</div>
</article>
<aside className={`${panel} h-fit p-5`}>
<p className={sectionTitle}>Snapshot</p>
<dl className="mt-4 grid gap-3 text-sm">
<InfoRow label="Market" value={brief.market} />
<InfoRow label="Settlement source" value={brief.settlementSource} />
<InfoRow label="Updated" value={formatDateTime(brief.updatedAt)} />
<InfoRow label="Freshness" value={brief.dataFreshness} />
</dl>
<div className="mt-5 flex flex-col gap-3">
<PublicContentCta
className={primaryButton}
href="/terminal"
payload={{ city: brief.city, date: brief.date, cta: "terminal" }}
>
{brief.primaryCtaLabel}
</PublicContentCta>
<Link className={secondaryButton} href="/briefs">
All public briefs
</Link>
</div>
</aside>
</section>
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
<div className={`${panel} p-5`}>
<p className={sectionTitle}>Checks before acting</p>
<ul className="mt-4 grid gap-3">
{brief.checkpoints.map((checkpoint) => (
<li className={bodyText} key={checkpoint}>
{checkpoint}
</li>
))}
</ul>
</div>
<div className={`${panel} p-5`}>
<p className={sectionTitle}>Distribution copy</p>
<p className={`${bodyText} mt-4`}>{brief.distributionText}</p>
<div className="mt-4">
<PublicContentOutboundLink
className={secondaryButton}
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(brief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(brief)))}`}
payload={{ city: brief.city, date: brief.date, destination: "x_intent" }}
>
Share on X
</PublicContentOutboundLink>
</div>
</div>
</section>
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>Methodology links</p>
<div className="mt-4">
<MethodologyLinks slugs={brief.methodologySlugs} />
</div>
</div>
<div>
<p className={sectionTitle}>Source links</p>
<div className="mt-4">
<SourceLinks slugs={brief.sourceSlugs} />
</div>
</div>
</section>
<p className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
{brief.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) {
return new Intl.DateTimeFormat("en", {
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,110 @@
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(
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 slugs={["deb", "settlement-sources"]}') &&
publicPages.includes('SourceLinks slugs={["mgm", "metar", "hko", "noaa"]}') &&
briefsIndex.includes("Weather Market Brief"),
"brief index must cross-link to DEB methodology and settlement source pages",
);
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",
);
}
+413
View File
@@ -0,0 +1,413 @@
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 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"],
},
];
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_feature_clicked"
| "paywall_viewed" | "paywall_viewed"
| "checkout_started" | "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 CLIENT_ID_KEY = "polyweather:analytics:client-id";
const SESSION_ID_KEY = "polyweather:analytics:session-id"; const SESSION_ID_KEY = "polyweather:analytics:session-id";
+7
View File
@@ -34,6 +34,11 @@ export const opsApi = {
async funnel(days = 30) { async funnel(days = 30) {
const raw = await opsFetch<{ const raw = await opsFetch<{
events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>; 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 }[] }>; diagnostics?: Record<string, { total?: number; unique_actors?: number; by_reason?: { name: string; count: number }[] }>;
rates?: Record<string, number>; rates?: Record<string, number>;
traffic?: { traffic?: {
@@ -68,6 +73,8 @@ export const opsApi = {
return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev }; return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev };
}); });
return { return {
content: raw?.content ?? {},
contentEvents: raw?.content_events ?? {},
diagnostics: raw?.diagnostics ?? {}, diagnostics: raw?.diagnostics ?? {},
rates: raw?.rates, rates: raw?.rates,
steps, steps,
+43
View File
@@ -2672,6 +2672,12 @@ class DBManager:
"payment_start", "payment_start",
"payment_success", "payment_success",
] ]
content_event_names = [
"brief_view",
"brief_cta_click",
"methodology_view",
"social_outbound_click",
]
diagnostic_event_names = ["degraded_auth_profile"] diagnostic_event_names = ["degraded_auth_profile"]
event_aliases = { event_aliases = {
"landing_view": ("landing_view",), "landing_view": ("landing_view",),
@@ -2697,6 +2703,20 @@ class DBManager:
} }
actor_sets: Dict[str, set[str]] = {name: set() for name in event_names} 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} 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]] = { diagnostics: Dict[str, Dict[str, Any]] = {
name: {"total": 0, "unique_actors": 0, "by_reason": []} name: {"total": 0, "unique_actors": 0, "by_reason": []}
for name in diagnostic_event_names for name in diagnostic_event_names
@@ -2711,6 +2731,8 @@ class DBManager:
country_counts: Counter = Counter() country_counts: Counter = Counter()
device_counts: Counter = Counter() device_counts: Counter = Counter()
landing_path_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]: def _payload(row: Dict[str, Any]) -> Dict[str, Any]:
payload = row.get("payload") payload = row.get("payload")
@@ -2756,6 +2778,19 @@ class DBManager:
diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1 diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1
continue 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) event_type = alias_to_event.get(raw_event_type)
if not event_type: if not event_type:
continue continue
@@ -2778,6 +2813,9 @@ class DBManager:
for name in event_names: for name in event_names:
summary[name]["unique_users"] = len(user_sets[name]) summary[name]["unique_users"] = len(user_sets[name])
summary[name]["unique_actors"] = len(actor_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: for name in diagnostic_event_names:
diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name]) diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name])
diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6) diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6)
@@ -2793,6 +2831,11 @@ class DBManager:
"window_days": safe_days, "window_days": safe_days,
"since": since_dt.isoformat(), "since": since_dt.isoformat(),
"events": summary, "events": summary,
"content_events": content_summary,
"content": {
"paths": _top(content_path_counts),
"cities": _top(content_city_counts),
},
"diagnostics": diagnostics, "diagnostics": diagnostics,
"traffic": { "traffic": {
"referrers": _top(referrer_counts), "referrers": _top(referrer_counts),
+19 -5
View File
@@ -2470,18 +2470,32 @@ class PaymentContractCheckoutService:
else: else:
self._require_user_wallet(user_id, from_addr) self._require_user_wallet(user_id, from_addr)
validation_pending_reasons = {
"payment_tx_validation_failed",
"tx_not_mined",
}
try: try:
validation = self._validate_loaded_intent_tx(intent, tx_hash_text) validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
except Exception as exc: except Exception as exc:
raise PaymentCheckoutError( if intent.payment_mode != "direct":
400, raise PaymentCheckoutError(
f"payment_tx_validation_failed: {exc}", 400,
) from exc 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")): if not bool(validation.get("valid")):
reason = str(validation.get("reason") or "payment_tx_invalid").strip() reason = str(validation.get("reason") or "payment_tx_invalid").strip()
detail = str(validation.get("detail") or reason).strip() detail = str(validation.get("detail") or reason).strip()
message = reason if detail == reason else f"{reason}: {detail}" 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) now_iso = _to_iso(now)
self._rest( self._rest(
+21
View File
@@ -94,6 +94,13 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
1, 1,
)[0] )[0]
warmer_block = compose.split(" polyweather_warmer:", 1)[1].split( 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:", "\nx-polyweather-base:",
1, 1,
)[0] )[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: bot" in compose
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
assert "POLYWEATHER_SERVICE_ROLE: warmer" in warmer_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 "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_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
assert "POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}" in web_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: 'false'" in web_block
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_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 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_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_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 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 "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 "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 "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 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 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) _setup_env(monkeypatch, tmp_path)
service = PaymentContractCheckoutService() service = PaymentContractCheckoutService()
tx_hash = "0x" + "7" * 64 tx_hash = "0x" + "7" * 64
submitted = {}
transactions = []
monkeypatch.setattr( monkeypatch.setattr(
service, service,
@@ -458,17 +460,76 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
return [] return []
if method == "GET" and table == "payment_intents": if method == "GET" and table == "payment_intents":
return [] 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) monkeypatch.setattr(service, "_rest", fake_rest)
try: result = service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
except Exception as exc: assert result["status"] == "submitted"
assert getattr(exc, "status_code", None) == 400 assert submitted["status"] == "submitted"
assert "tx_not_mined" in getattr(exc, "detail", "") assert submitted["tx_hash"] == tx_hash
else: assert transactions[0]["status"] == "submitted"
raise AssertionError("expected tx_not_mined rejection")
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): 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 datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.requests import Request from starlette.requests import Request
@@ -9,13 +10,16 @@ import web.services.auth_api as auth_api
from web.app import app from web.app import app
import web.routes as routes import web.routes as routes
import web.services.ops_api as ops_api 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_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service 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_api as city_api
import web.services.city_runtime as city_runtime import web.services.city_runtime as city_runtime
from web.services.observation_freshness import build_observation_freshness from web.services.observation_freshness import build_observation_freshness
from web.scan_terminal_cache import scan_terminal_cache_key from web.scan_terminal_cache import scan_terminal_cache_key
from src.database.runtime_state import TruthRecordRepository from src.database.runtime_state import RuntimeStateDB, TruthRecordRepository
from src.utils.metrics import export_prometheus_metrics
client = TestClient(app) 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 'sse_connections' in payload['realtime']
assert 'truth_records' in payload['training_data'] assert 'truth_records' in payload['training_data']
assert 'training_features' 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 'city_coverage' in payload['training_data']
assert 'model_city_coverage' in payload['training_data'] assert 'model_city_coverage' in payload['training_data']
assert 'metar_entries' in payload['cache'] 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 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): def test_system_cache_status_requires_ops_admin(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
@@ -202,6 +287,10 @@ def test_standard_growth_funnel_events_are_trackable():
"payment_start", "payment_start",
"payment_success", "payment_success",
"degraded_auth_profile", "degraded_auth_profile",
"brief_view",
"brief_cta_click",
"methodology_view",
"social_outbound_click",
}.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS) }.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"] 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): def test_ops_source_health_flags_expected_official_sources(monkeypatch):
class FakeCache: class FakeCache:
def get_city_cache(self, kind, city): 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: except Exception as exc:
return {"ok": False, "error": str(exc), "row_count": 0, "cities_count": 0} return {"ok": False, "error": str(exc), "row_count": 0, "cities_count": 0}
max_date = row["max_date"]
return { return {
"ok": True, "ok": True,
"row_count": int(row["row_count"] or 0), "row_count": int(row["row_count"] or 0),
"cities_count": int(row["cities_count"] or 0), "cities_count": int(row["cities_count"] or 0),
"min_date": row["min_date"], "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]: def _truth_source_counts(conn) -> Dict[str, int]:
try: try:
rows = conn.execute( rows = conn.execute(
@@ -293,11 +306,17 @@ def _training_data_summary(account_db, city_registry) -> Dict[str, Any]:
import sqlite3 import sqlite3
db_path = account_db.db_path 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_records = {"ok": False, "row_count": 0, "cities_count": 0}
truth_revisions = {"ok": False, "row_count": 0} truth_revisions = {"ok": False, "row_count": 0}
training_features = {"ok": False, "row_count": 0, "cities_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: try:
with connect_sqlite(db_path, row_factory=sqlite3.Row) as conn: 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") truth_records = _table_date_summary(conn, "truth_records_store")
if truth_records.get("ok"): if truth_records.get("ok"):
truth_records["source_counts"] = _truth_source_counts(conn) 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_path": db_path,
"db_ok": False, "db_ok": False,
"error": str(exc), "error": str(exc),
"daily_records": daily_records,
"truth_records": truth_records, "truth_records": truth_records,
"truth_revisions": truth_revisions, "truth_revisions": truth_revisions,
"training_features": training_features, "training_features": training_features,
"stale": True,
"stale_threshold_days": stale_threshold_days,
"city_coverage": {}, "city_coverage": {},
"model_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 { return {
"db_path": db_path, "db_path": db_path,
"db_ok": True, "db_ok": True,
"daily_records": daily_records,
"truth_records": truth_records, "truth_records": truth_records,
"truth_revisions": truth_revisions, "truth_revisions": truth_revisions,
"training_features": training_features, "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, "city_coverage": city_coverage,
"model_city_coverage": _model_city_coverage_summary( "model_city_coverage": _model_city_coverage_summary(
city_coverage.get("entries") or [], city_coverage.get("entries") or [],
+4
View File
@@ -82,6 +82,10 @@ TRACKABLE_ANALYTICS_EVENTS = {
"paywall_viewed", "paywall_viewed",
"checkout_started", "checkout_started",
"checkout_succeeded", "checkout_succeeded",
"brief_view",
"brief_cta_click",
"methodology_view",
"social_outbound_click",
} }
DEFAULT_STATUS_CITIES = [ DEFAULT_STATUS_CITIES = [
+50
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import time import time
import os import os
from collections import Counter from collections import Counter
from datetime import datetime, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi import BackgroundTasks, Request from fastapi import BackgroundTasks, Request
@@ -13,6 +14,7 @@ from fastapi.responses import PlainTextResponse
from loguru import logger from loguru import logger
from src.database.db_manager import DBManager 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 src.utils.metrics import export_prometheus_metrics, gauge_set
from web.core import build_health_payload, build_system_status_payload from web.core import build_health_payload, build_system_status_payload
import web.routes as legacy_routes 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)) gauge_set("polyweather_sqlite_db_size_bytes", os.path.getsize(db_path))
except OSError: except OSError:
pass 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()