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