Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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..."
|
echo "Updating cache warmer..."
|
||||||
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
|
compose_up_retry "cache warmer" -d --no-deps polyweather_warmer
|
||||||
|
|
||||||
|
echo "Updating training settlement worker..."
|
||||||
|
compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement
|
||||||
|
|
||||||
echo "Updating frontend..."
|
echo "Updating frontend..."
|
||||||
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
compose_up_retry "frontend" -d --no-deps polyweather_frontend
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,49 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||||
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||||
|
polyweather_training_settlement:
|
||||||
|
command: python -m web.training_settlement_worker
|
||||||
|
container_name: polyweather_training_settlement
|
||||||
|
depends_on:
|
||||||
|
polyweather_redis:
|
||||||
|
condition: service_healthy
|
||||||
|
polyweather_web:
|
||||||
|
condition: service_started
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "3"
|
||||||
|
cpus: ${POLYWEATHER_TRAINING_SETTLEMENT_CPUS:-0.50}
|
||||||
|
env_file: *id001
|
||||||
|
environment:
|
||||||
|
POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}
|
||||||
|
POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'
|
||||||
|
POLYWEATHER_REDIS_URL: ${POLYWEATHER_REDIS_URL:-redis://polyweather_redis:6379/0}
|
||||||
|
POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'
|
||||||
|
POLYWEATHER_SERVICE_ROLE: training_settlement
|
||||||
|
POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INITIAL_DELAY_SEC:-60}
|
||||||
|
POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: ${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}
|
||||||
|
POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS: ${POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS:-10}
|
||||||
|
healthcheck:
|
||||||
|
interval: 60s
|
||||||
|
retries: 3
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- import sqlite3; c=sqlite3.connect('/var/lib/polyweather/polyweather.db');
|
||||||
|
c.execute('SELECT 1'); c.close()
|
||||||
|
timeout: 10s
|
||||||
|
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||||
|
mem_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEM_LIMIT:-768m}
|
||||||
|
memswap_limit: ${POLYWEATHER_TRAINING_SETTLEMENT_MEMSWAP_LIMIT:-1g}
|
||||||
|
pids_limit: 256
|
||||||
|
restart: unless-stopped
|
||||||
|
user: ${UID:-1000}:${GID:-1000}
|
||||||
|
volumes:
|
||||||
|
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/var/lib/polyweather
|
||||||
|
- ${POLYWEATHER_RUNTIME_DATA_DIR:-/var/lib/polyweather}:/app/data
|
||||||
x-polyweather-base:
|
x-polyweather-base:
|
||||||
env_file: *id001
|
env_file: *id001
|
||||||
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# DEB Training Settlement Worker Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Restore automatic DEB training data freshness after the realtime collector/canonical-cache split.
|
||||||
|
|
||||||
|
**Architecture:** Add a dedicated low-frequency training settlement service that runs city analysis for forecast/DEB snapshots and reconciles recent settled actual highs. Keep it separate from the high-frequency observation collector so user-facing realtime updates stay light.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, FastAPI service modules, Docker Compose, pytest, existing `update_daily_record()` and `reconcile_recent_actual_highs()` paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Training Settlement Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/training_settlement_service.py`
|
||||||
|
- Create: `tests/test_training_settlement_service.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing service test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities():
|
||||||
|
calls = {"analysis": [], "reconcile": []}
|
||||||
|
|
||||||
|
def analysis_runner(city):
|
||||||
|
calls["analysis"].append(city)
|
||||||
|
return {"city": city, "deb": {"prediction": 31.2}}
|
||||||
|
|
||||||
|
def reconciler(city, *, lookback_days):
|
||||||
|
calls["reconcile"].append((city, lookback_days))
|
||||||
|
return {"ok": True, "updated": 1}
|
||||||
|
|
||||||
|
result = run_training_settlement_cycle(
|
||||||
|
city_registry={
|
||||||
|
"shanghai": {"icao": "ZSSS", "settlement_source": "metar"},
|
||||||
|
"legacy": {"settlement_source": "wunderground"},
|
||||||
|
},
|
||||||
|
analysis_runner=analysis_runner,
|
||||||
|
actual_reconciler=reconciler,
|
||||||
|
lookback_days=9,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["processed"] == 1
|
||||||
|
assert calls["analysis"] == ["shanghai"]
|
||||||
|
assert calls["reconcile"] == [("shanghai", 9)]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_training_settlement_service.py::test_training_settlement_cycle_runs_analysis_and_reconciles_supported_cities -q`
|
||||||
|
|
||||||
|
Expected: FAIL because `web.training_settlement_service` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the service**
|
||||||
|
|
||||||
|
Create `run_training_settlement_cycle()` with injectable `city_registry`, `analysis_runner`, and `actual_reconciler`. Default analysis runner calls `web.analysis_service._analyze(city, force_refresh=False, detail_mode="panel")`; default reconciler calls `src.analysis.deb_algorithm.reconcile_recent_actual_highs()`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_training_settlement_service.py -q`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 2: Worker Entrypoint And Compose
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/training_settlement_worker.py`
|
||||||
|
- Modify: `docker-compose.yml`
|
||||||
|
- Modify: `tests/test_deployment_runtime_config.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing deployment tests**
|
||||||
|
|
||||||
|
Assert the compose file contains `polyweather_training_settlement`, command `python -m web.training_settlement_worker`, role `training_settlement`, and conservative interval/lookback environment variables.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the deployment test to verify it fails**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_deployment_runtime_config.py::test_runtime_compose_splits_realtime_workers -q`
|
||||||
|
|
||||||
|
Expected: FAIL because the worker service is absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement worker and compose service**
|
||||||
|
|
||||||
|
The worker loops `run_training_settlement_cycle()` every `POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC` seconds, with initial delay and lookback from environment.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused tests**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py -q`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Stale Monitoring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/diagnostics/health.py`
|
||||||
|
- Modify: `web/services/system_api.py`
|
||||||
|
- Modify: `tests/test_web_observability.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing observability tests**
|
||||||
|
|
||||||
|
Assert system status training summaries include `stale_days` for daily/truth/features, and Prometheus exports stale gauges for training data.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused observability tests to verify failure**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
|
||||||
|
|
||||||
|
Expected: FAIL because stale fields/gauges are absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement stale summary and gauges**
|
||||||
|
|
||||||
|
Add date-diff calculation against UTC today. Export `polyweather_daily_records_stale_days`, `polyweather_truth_records_stale_days`, `polyweather_training_features_stale_days`, and `polyweather_training_data_stale`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused observability tests**
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_web_observability.py::test_system_status_includes_training_data tests/test_web_observability.py::test_metrics_endpoint_returns_prometheus_payload_for_ops_admin -q`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 4: Verification And Backfill
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Optional create: `scripts/backfill_training_settlement.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run backend checks**
|
||||||
|
|
||||||
|
Run: `python -m ruff check .`
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_training_settlement_service.py tests/test_deployment_runtime_config.py tests/test_web_observability.py -q`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run local one-shot cycle**
|
||||||
|
|
||||||
|
Run: `python -m web.training_settlement_worker --once --lookback-days 10 --cities shanghai`
|
||||||
|
|
||||||
|
Expected: JSON-like log output with `ok=True`; local DB should get a fresh row for the current local target date if analysis succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Production note**
|
||||||
|
|
||||||
|
Missed forecast snapshots from 2026-06-15 to 2026-06-22 cannot be reconstructed honestly unless archived city analysis payloads exist. The worker restores forward automatic samples; actual-high truth can still be reconciled for supported settlement sources.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||||
|
import {
|
||||||
|
PUBLIC_BRIEFS,
|
||||||
|
absolutePublicUrl,
|
||||||
|
briefPath,
|
||||||
|
getBrief,
|
||||||
|
} from "@/content/public-content";
|
||||||
|
|
||||||
|
type BriefPageParams = {
|
||||||
|
city: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return PUBLIC_BRIEFS.map((brief) => ({
|
||||||
|
city: brief.city,
|
||||||
|
date: brief.date,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<BriefPageParams>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { city, date } = await params;
|
||||||
|
const brief = getBrief(city, date);
|
||||||
|
|
||||||
|
if (!brief) {
|
||||||
|
return {
|
||||||
|
title: "Brief not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = briefPath(brief);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: brief.title,
|
||||||
|
description: brief.description,
|
||||||
|
alternates: {
|
||||||
|
canonical: pathname,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "article",
|
||||||
|
title: brief.title,
|
||||||
|
description: brief.description,
|
||||||
|
url: pathname,
|
||||||
|
publishedTime: brief.publishedAt,
|
||||||
|
modifiedTime: brief.updatedAt,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary",
|
||||||
|
title: brief.title,
|
||||||
|
description: brief.description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BriefDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<BriefPageParams>;
|
||||||
|
}) {
|
||||||
|
const { city, date } = await params;
|
||||||
|
const brief = getBrief(city, date);
|
||||||
|
|
||||||
|
if (!brief) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = briefPath(brief);
|
||||||
|
const jsonLd = [
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
headline: brief.title,
|
||||||
|
description: brief.description,
|
||||||
|
datePublished: brief.publishedAt,
|
||||||
|
dateModified: brief.updatedAt,
|
||||||
|
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||||
|
author: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "PolyWeather",
|
||||||
|
url: "https://polyweather.top",
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "PolyWeather",
|
||||||
|
url: "https://polyweather.top",
|
||||||
|
},
|
||||||
|
about: [
|
||||||
|
brief.cityName,
|
||||||
|
brief.market,
|
||||||
|
brief.settlementSource,
|
||||||
|
"DEB forecast methodology",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 1,
|
||||||
|
name: "Briefs",
|
||||||
|
item: absolutePublicUrl("/briefs"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
position: 2,
|
||||||
|
name: brief.cityName,
|
||||||
|
item: absolutePublicUrl(pathname),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<BriefDetailPageView brief={brief} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Weather Market Brief",
|
||||||
|
description:
|
||||||
|
"Public PolyWeather market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/briefs",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: "Weather Market Brief | PolyWeather",
|
||||||
|
description:
|
||||||
|
"Public market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
|
||||||
|
url: "/briefs",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BriefsPage() {
|
||||||
|
return <BriefsIndexPageView />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import { RegisterSW } from "@/components/dashboard/RegisterSW";
|
import { RegisterSW } from "@/components/dashboard/RegisterSW";
|
||||||
|
import { MicrosoftClarity } from "@/components/observability/MicrosoftClarity";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -89,6 +90,7 @@ export default function RootLayout({
|
|||||||
</a>
|
</a>
|
||||||
<main id="main-content">{children}</main>
|
<main id="main-content">{children}</main>
|
||||||
<RegisterSW />
|
<RegisterSW />
|
||||||
|
<MicrosoftClarity />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 type { MetadataRoute } from "next";
|
||||||
|
import {
|
||||||
|
METHODOLOGY_PAGES,
|
||||||
|
PUBLIC_BRIEFS,
|
||||||
|
SOURCE_PAGES,
|
||||||
|
} from "@/content/public-content";
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const baseUrl = "https://polyweather.top";
|
const baseUrl = "https://polyweather.top";
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 1.0,
|
priority: 1.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/auth/login`,
|
url: `${baseUrl}/auth/login`,
|
||||||
lastModified: new Date(),
|
lastModified: now,
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/briefs`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/methodology`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/sources`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
|
...PUBLIC_BRIEFS.map((brief) => ({
|
||||||
|
url: `${baseUrl}/briefs/${brief.city}/${brief.date}`,
|
||||||
|
lastModified: new Date(brief.updatedAt),
|
||||||
|
changeFrequency: "daily" as const,
|
||||||
|
priority: 0.85,
|
||||||
|
})),
|
||||||
|
...METHODOLOGY_PAGES.map((page) => ({
|
||||||
|
url: `${baseUrl}/methodology/${page.slug}`,
|
||||||
|
lastModified: new Date(page.updatedAt),
|
||||||
|
changeFrequency: "monthly" as const,
|
||||||
|
priority: 0.75,
|
||||||
|
})),
|
||||||
|
...SOURCE_PAGES.map((source) => ({
|
||||||
|
url: `${baseUrl}/sources/${source.slug}`,
|
||||||
|
lastModified: new Date(source.updatedAt),
|
||||||
|
changeFrequency: "monthly" as const,
|
||||||
|
priority: 0.7,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
paymentFlowSource.includes("/validate"),
|
||||||
"manual payment flow must validate tx hashes with the backend before submission",
|
"manual payment flow must validate tx hashes with the backend before submission",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
paymentFlowSource.includes("confirmRes.status === 503") &&
|
||||||
|
paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') &&
|
||||||
|
paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'),
|
||||||
|
"manual payment confirm must treat transient payment RPC failures as pending after tx hash submission",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
|
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
|
||||||
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
|
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
|
||||||
|
|||||||
@@ -826,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
const lowerRaw = raw.toLowerCase();
|
const lowerRaw = raw.toLowerCase();
|
||||||
const maybePending =
|
const maybePending =
|
||||||
confirmRes.status === 408 ||
|
confirmRes.status === 408 ||
|
||||||
|
(confirmRes.status === 503 &&
|
||||||
|
(lowerRaw.includes("cannot connect payment rpc") ||
|
||||||
|
lowerRaw.includes("payment rpc chain mismatch"))) ||
|
||||||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
|
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
|
||||||
if (maybePending) {
|
if (maybePending) {
|
||||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
||||||
|
|||||||
@@ -803,6 +803,87 @@ export function runTests() {
|
|||||||
"Ankara scan-row-seeded airport-primary curve should default to MGM instead of NOAA MADIS when source metadata is missing",
|
"Ankara scan-row-seeded airport-primary curve should default to MGM instead of NOAA MADIS when source metadata is missing",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const staleAnkaraDetail = toFullChartDetail({
|
||||||
|
localDate: "2026-06-23",
|
||||||
|
localTime: "13:51",
|
||||||
|
times: ["12:00", "13:00", "14:00", "15:00"],
|
||||||
|
temps: [22, 24, 25, 24],
|
||||||
|
airportPrimary: {
|
||||||
|
temp: 27.1,
|
||||||
|
obs_time: "2026-06-23T10:39:00Z",
|
||||||
|
source_code: "mgm",
|
||||||
|
source_label: "MGM",
|
||||||
|
},
|
||||||
|
airportPrimaryTodayObs: [["2026-06-23T10:39:00Z", 27.1]],
|
||||||
|
metarTodayObs: [["2026-06-23T10:20:00Z", 24.0]],
|
||||||
|
debHourlyPath: {
|
||||||
|
times: ["12:00", "13:00", "14:00", "15:00"],
|
||||||
|
temps: [20.5, 21.0, 21.2, 21.4],
|
||||||
|
},
|
||||||
|
modelTimes: ["12:00", "13:00", "14:00", "15:00"],
|
||||||
|
modelCurves: { ECMWF: [23.8, 24.4, 24.7, 24.0] },
|
||||||
|
} as any);
|
||||||
|
const freshAnkaraObservation = observationPayloadToSnapshot({
|
||||||
|
city: "ankara",
|
||||||
|
name: "ankara",
|
||||||
|
display_name: "Ankara",
|
||||||
|
local_date: "2026-06-23",
|
||||||
|
local_time: "13:30",
|
||||||
|
utc_offset_seconds: 3 * 60 * 60,
|
||||||
|
current: {
|
||||||
|
temp: 24.5,
|
||||||
|
source_code: "metar",
|
||||||
|
source_label: "METAR",
|
||||||
|
settlement_source: "metar",
|
||||||
|
settlement_source_label: "METAR",
|
||||||
|
station_code: "LTAC",
|
||||||
|
obs_time: "2026-06-23T10:30:00Z",
|
||||||
|
},
|
||||||
|
airport_current: {
|
||||||
|
temp: 24.5,
|
||||||
|
source_code: "metar",
|
||||||
|
source_label: "METAR",
|
||||||
|
station_code: "LTAC",
|
||||||
|
obs_time: "2026-06-23T10:30:00Z",
|
||||||
|
},
|
||||||
|
airport_primary: {
|
||||||
|
temp: 24.5,
|
||||||
|
source_code: "metar",
|
||||||
|
source_label: "METAR",
|
||||||
|
station_code: "LTAC",
|
||||||
|
obs_time: "2026-06-23T10:30:00Z",
|
||||||
|
},
|
||||||
|
timeseries: {
|
||||||
|
metar_today_obs: [{ time: "13:30", temp: 24.5 }],
|
||||||
|
},
|
||||||
|
metar_today_obs: [{ time: "13:30", temp: 24.5 }],
|
||||||
|
} as any);
|
||||||
|
const refreshedAnkaraDetail = mergeObservationSnapshotIntoHourly(
|
||||||
|
staleAnkaraDetail,
|
||||||
|
freshAnkaraObservation,
|
||||||
|
);
|
||||||
|
const refreshedAnkaraChart = buildFullDayChartData(
|
||||||
|
{
|
||||||
|
city: "ankara",
|
||||||
|
local_date: "2026-06-23",
|
||||||
|
local_time: "13:51",
|
||||||
|
tz_offset_seconds: 3 * 60 * 60,
|
||||||
|
airport: "LTAC",
|
||||||
|
temp_symbol: "°C",
|
||||||
|
} as any,
|
||||||
|
refreshedAnkaraDetail,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const refreshedAnkaraAirportSeries = refreshedAnkaraChart.series.find((item) => item.key === "madis");
|
||||||
|
assert(
|
||||||
|
!refreshedAnkaraAirportSeries?.values.some((value) => value === 27.1),
|
||||||
|
"Ankara live observation refresh should discard stale cached MGM airport-primary points when the latest source is METAR",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
refreshedAnkaraAirportSeries?.label === "LTAC METAR",
|
||||||
|
`Ankara refreshed airport-primary curve should be labelled as LTAC METAR, got ${refreshedAnkaraAirportSeries?.label}`,
|
||||||
|
);
|
||||||
|
|
||||||
const guangzhouRunwayWithBadMadisChart = buildFullDayChartData(
|
const guangzhouRunwayWithBadMadisChart = buildFullDayChartData(
|
||||||
{
|
{
|
||||||
city: "guangzhou",
|
city: "guangzhou",
|
||||||
|
|||||||
@@ -901,6 +901,16 @@ function canonicalAirportPrimarySourceLabel(hourly: ChartRenderState) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function airportPrimaryHasMetarSource(hourly: ChartRenderState) {
|
||||||
|
const primary = hourly?.airportPrimary;
|
||||||
|
const tokens = [
|
||||||
|
primary?.source_code,
|
||||||
|
primary?.source_label,
|
||||||
|
(primary as any)?.source,
|
||||||
|
].map((value) => String(value || "").trim().toLowerCase());
|
||||||
|
return tokens.some((value) => value === "metar" || value.includes(" metar"));
|
||||||
|
}
|
||||||
|
|
||||||
function airportCodeForSeriesLabel(
|
function airportCodeForSeriesLabel(
|
||||||
hourly: ChartRenderState,
|
hourly: ChartRenderState,
|
||||||
row?: ScanOpportunityRow | null,
|
row?: ScanOpportunityRow | null,
|
||||||
@@ -943,12 +953,15 @@ function airportPrimarySeriesLabel(
|
|||||||
const cityKey = normalizeCityKey(row?.city);
|
const cityKey = normalizeCityKey(row?.city);
|
||||||
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
|
const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly);
|
||||||
if (canonicalLabel === "MGM") return canonicalLabel;
|
if (canonicalLabel === "MGM") return canonicalLabel;
|
||||||
|
const stationCode = airportCodeForSeriesLabel(hourly, row);
|
||||||
|
if (airportPrimaryHasMetarSource(hourly)) {
|
||||||
|
return stationCode ? `${stationCode} METAR` : "METAR";
|
||||||
|
}
|
||||||
if ((cityKey === "ankara" || cityKey === "istanbul") && (!canonicalLabel || canonicalLabel === "NOAA MADIS")) {
|
if ((cityKey === "ankara" || cityKey === "istanbul") && (!canonicalLabel || canonicalLabel === "NOAA MADIS")) {
|
||||||
return "MGM";
|
return "MGM";
|
||||||
}
|
}
|
||||||
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
|
const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim();
|
||||||
if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel;
|
if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel;
|
||||||
const stationCode = airportCodeForSeriesLabel(hourly, row);
|
|
||||||
const isUsAirport = isUsAirportCode(stationCode);
|
const isUsAirport = isUsAirportCode(stationCode);
|
||||||
if (!isUsAirport) {
|
if (!isUsAirport) {
|
||||||
if (canonicalLabel && canonicalLabel !== "NOAA MADIS") return canonicalLabel;
|
if (canonicalLabel && canonicalLabel !== "NOAA MADIS") return canonicalLabel;
|
||||||
@@ -1316,6 +1329,17 @@ function conditionObservationTime(source: AirportCurrentConditions | null | unde
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function conditionObservationSourceKey(source: AirportCurrentConditions | null | undefined) {
|
||||||
|
const tokens = [
|
||||||
|
source?.source_code,
|
||||||
|
(source as any)?.source,
|
||||||
|
source?.source_label,
|
||||||
|
].map((value) => String(value || "").trim().toLowerCase());
|
||||||
|
if (tokens.some((value) => value === "mgm" || value.includes("turkey_mgm"))) return "mgm";
|
||||||
|
if (tokens.some((value) => value === "metar" || value.includes(" metar"))) return "metar";
|
||||||
|
return tokens.find(Boolean) || "";
|
||||||
|
}
|
||||||
|
|
||||||
function mergeAirportCondition(
|
function mergeAirportCondition(
|
||||||
base: AirportCurrentConditions | null | undefined,
|
base: AirportCurrentConditions | null | undefined,
|
||||||
live: AirportCurrentConditions | null | undefined,
|
live: AirportCurrentConditions | null | undefined,
|
||||||
@@ -1338,6 +1362,19 @@ function mergeAirportCondition(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function airportPrimaryObservationSourceChanged(
|
||||||
|
base: ChartRenderState,
|
||||||
|
live: ChartRenderState,
|
||||||
|
) {
|
||||||
|
const baseSource =
|
||||||
|
conditionObservationSourceKey(base?.airportPrimary) ||
|
||||||
|
conditionObservationSourceKey(base?.airportCurrent);
|
||||||
|
const liveSource =
|
||||||
|
conditionObservationSourceKey(live?.airportPrimary) ||
|
||||||
|
conditionObservationSourceKey(live?.airportCurrent);
|
||||||
|
return Boolean(baseSource && liveSource && baseSource !== liveSource);
|
||||||
|
}
|
||||||
|
|
||||||
function runwayHistoryPointKey(point: Record<string, unknown>) {
|
function runwayHistoryPointKey(point: Record<string, unknown>) {
|
||||||
const time = String(
|
const time = String(
|
||||||
point.timestamp ??
|
point.timestamp ??
|
||||||
@@ -1677,6 +1714,10 @@ function mergeHourlyWithLiveObservations(
|
|||||||
runway_plate_history: runwayPlateHistory,
|
runway_plate_history: runwayPlateHistory,
|
||||||
} as AmosData
|
} as AmosData
|
||||||
: detailSource.amos;
|
: detailSource.amos;
|
||||||
|
const useLiveAirportPrimary =
|
||||||
|
airportPrimaryObservationSourceChanged(base, live) &&
|
||||||
|
Array.isArray(live.airportPrimaryTodayObs) &&
|
||||||
|
live.airportPrimaryTodayObs.length > 0;
|
||||||
return {
|
return {
|
||||||
...detailSource,
|
...detailSource,
|
||||||
localDate,
|
localDate,
|
||||||
@@ -1696,16 +1737,22 @@ function mergeHourlyWithLiveObservations(
|
|||||||
: forecastFallback.probabilities || null,
|
: forecastFallback.probabilities || null,
|
||||||
runwayPlateHistory,
|
runwayPlateHistory,
|
||||||
amos,
|
amos,
|
||||||
airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
|
airportCurrent: useLiveAirportPrimary
|
||||||
airportPrimary: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate),
|
? live.airportCurrent || live.airportPrimary || null
|
||||||
|
: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate),
|
||||||
|
airportPrimary: useLiveAirportPrimary
|
||||||
|
? live.airportPrimary || live.airportCurrent || null
|
||||||
|
: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate),
|
||||||
settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined,
|
settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined,
|
||||||
settlementStationCode: detailSource.settlementStationCode || forecastFallback.settlementStationCode || row?.metar_context?.station || null,
|
settlementStationCode: detailSource.settlementStationCode || forecastFallback.settlementStationCode || row?.metar_context?.station || null,
|
||||||
settlementStationLabel: detailSource.settlementStationLabel || forecastFallback.settlementStationLabel || null,
|
settlementStationLabel: detailSource.settlementStationLabel || forecastFallback.settlementStationLabel || null,
|
||||||
metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined,
|
metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined,
|
||||||
airportPrimaryTodayObs: mergeRawObservationPoints(
|
airportPrimaryTodayObs: useLiveAirportPrimary
|
||||||
base.airportPrimaryTodayObs,
|
? live.airportPrimaryTodayObs
|
||||||
live.airportPrimaryTodayObs,
|
: mergeRawObservationPoints(
|
||||||
),
|
base.airportPrimaryTodayObs,
|
||||||
|
live.airportPrimaryTodayObs,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,9 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
|||||||
<Link href="/docs/chart-guide" className="hover:text-slate-950">
|
<Link href="/docs/chart-guide" className="hover:text-slate-950">
|
||||||
{isEn ? "Guide" : "读图"}
|
{isEn ? "Guide" : "读图"}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/briefs" className="hover:text-slate-950">
|
||||||
|
Weather Market Brief
|
||||||
|
</Link>
|
||||||
<a href="#pricing" className="hover:text-slate-950">
|
<a href="#pricing" className="hover:text-slate-950">
|
||||||
{isEn ? "Pricing" : "定价"}
|
{isEn ? "Pricing" : "定价"}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export function runTests() {
|
|||||||
"landing supported cities must render names and station codes from the generated city groups",
|
"landing supported cities must render names and station codes from the generated city groups",
|
||||||
);
|
);
|
||||||
assert(source.includes("#contact"), "landing navigation must expose the contact section");
|
assert(source.includes("#contact"), "landing navigation must expose the contact section");
|
||||||
|
assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets");
|
||||||
|
assert(source.includes("Weather Market Brief"), "landing page must label the public brief entry clearly");
|
||||||
assert(source.includes('id="contact"'), "landing page must include a contact section");
|
assert(source.includes('id="contact"'), "landing page must include a contact section");
|
||||||
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
|
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
|
||||||
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
const MICROSOFT_CLARITY_PROJECT_ID = "xbydcq2lu2";
|
||||||
|
|
||||||
|
const MICROSOFT_CLARITY_SCRIPT = `
|
||||||
|
(function(c,l,a,r,i,t,y){
|
||||||
|
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||||
|
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||||
|
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||||
|
})(window, document, "clarity", "script", "${MICROSOFT_CLARITY_PROJECT_ID}");
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function MicrosoftClarity() {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Script id="microsoft-clarity" strategy="afterInteractive">
|
||||||
|
{MICROSOFT_CLARITY_SCRIPT}
|
||||||
|
</Script>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const componentPath = path.join(projectRoot, "components", "observability", "MicrosoftClarity.tsx");
|
||||||
|
const layoutPath = path.join(projectRoot, "app", "layout.tsx");
|
||||||
|
|
||||||
|
assert(fs.existsSync(componentPath), "Microsoft Clarity component must exist");
|
||||||
|
|
||||||
|
const component = fs.readFileSync(componentPath, "utf8");
|
||||||
|
const layout = fs.readFileSync(layoutPath, "utf8");
|
||||||
|
|
||||||
|
assert(
|
||||||
|
component.includes('import Script from "next/script"') &&
|
||||||
|
component.includes('strategy="afterInteractive"'),
|
||||||
|
"Microsoft Clarity must use next/script and load after hydration",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
component.includes("xbydcq2lu2") &&
|
||||||
|
component.includes("https://www.clarity.ms/tag/") &&
|
||||||
|
component.includes('window, document, "clarity", "script"'),
|
||||||
|
"Microsoft Clarity must use the configured PolyWeather project script",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
layout.includes("@/components/observability/MicrosoftClarity") &&
|
||||||
|
layout.includes("<MicrosoftClarity />"),
|
||||||
|
"root layout must render Microsoft Clarity globally",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,16 +25,21 @@ export function runTests() {
|
|||||||
|
|
||||||
assert(
|
assert(
|
||||||
opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') &&
|
opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') &&
|
||||||
|
opsApi.includes("content_events?:") &&
|
||||||
|
opsApi.includes("contentEvents") &&
|
||||||
opsApi.includes("diagnostics?:") &&
|
opsApi.includes("diagnostics?:") &&
|
||||||
opsApi.includes("traffic?:") &&
|
opsApi.includes("traffic?:") &&
|
||||||
opsApi.includes("uniqueActors"),
|
opsApi.includes("uniqueActors"),
|
||||||
"ops funnel API client must preserve the full standard funnel and expose diagnostics/traffic dimensions",
|
"ops funnel API client must preserve the standard funnel and expose content/diagnostics/traffic dimensions",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
analyticsPage.includes("落地页访问") &&
|
analyticsPage.includes("落地页访问") &&
|
||||||
analyticsPage.includes("进入终端") &&
|
analyticsPage.includes("进入终端") &&
|
||||||
analyticsPage.includes("注册成功") &&
|
analyticsPage.includes("注册成功") &&
|
||||||
analyticsPage.includes("鉴权降级") &&
|
analyticsPage.includes("鉴权降级") &&
|
||||||
|
analyticsPage.includes("公开内容获客") &&
|
||||||
|
analyticsPage.includes("Brief 浏览") &&
|
||||||
|
analyticsPage.includes("briefCtaClick?.total") &&
|
||||||
analyticsPage.includes("来源与设备") &&
|
analyticsPage.includes("来源与设备") &&
|
||||||
analyticsPage.includes("paymentSuccess?.count") &&
|
analyticsPage.includes("paymentSuccess?.count") &&
|
||||||
!analyticsPage.includes("总注册") &&
|
!analyticsPage.includes("总注册") &&
|
||||||
|
|||||||
@@ -28,10 +28,17 @@ type AuthDiagnostic = {
|
|||||||
unique_actors?: number;
|
unique_actors?: number;
|
||||||
by_reason?: TopItem[];
|
by_reason?: TopItem[];
|
||||||
};
|
};
|
||||||
|
type ContentEventSummary = {
|
||||||
|
total?: number;
|
||||||
|
unique_users?: number;
|
||||||
|
unique_actors?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function AnalyticsPageClient() {
|
export function AnalyticsPageClient() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [funnel, setFunnel] = useState<FunnelStep[]>([]);
|
const [funnel, setFunnel] = useState<FunnelStep[]>([]);
|
||||||
|
const [contentEvents, setContentEvents] = useState<Record<string, ContentEventSummary>>({});
|
||||||
|
const [content, setContent] = useState<Record<string, TopItem[]>>({});
|
||||||
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
|
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
|
||||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
||||||
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
|
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
|
||||||
@@ -42,6 +49,8 @@ export function AnalyticsPageClient() {
|
|||||||
try {
|
try {
|
||||||
const data = await opsApi.funnel(days);
|
const data = await opsApi.funnel(days);
|
||||||
setFunnel(data.steps);
|
setFunnel(data.steps);
|
||||||
|
setContentEvents(data.contentEvents ?? {});
|
||||||
|
setContent(data.content ?? {});
|
||||||
setDiagnostics(data.diagnostics ?? {});
|
setDiagnostics(data.diagnostics ?? {});
|
||||||
setRates(data.rates ?? null);
|
setRates(data.rates ?? null);
|
||||||
setTraffic(data.traffic ?? {});
|
setTraffic(data.traffic ?? {});
|
||||||
@@ -65,6 +74,10 @@ export function AnalyticsPageClient() {
|
|||||||
const trialCreated = stepByKey.trial_created;
|
const trialCreated = stepByKey.trial_created;
|
||||||
const paymentStart = stepByKey.payment_start;
|
const paymentStart = stepByKey.payment_start;
|
||||||
const paymentSuccess = stepByKey.payment_success;
|
const paymentSuccess = stepByKey.payment_success;
|
||||||
|
const briefView = contentEvents.brief_view;
|
||||||
|
const briefCtaClick = contentEvents.brief_cta_click;
|
||||||
|
const methodologyView = contentEvents.methodology_view;
|
||||||
|
const socialOutboundClick = contentEvents.social_outbound_click;
|
||||||
const overallRate =
|
const overallRate =
|
||||||
landingView?.uniqueActors && paymentSuccess?.uniqueActors
|
landingView?.uniqueActors && paymentSuccess?.uniqueActors
|
||||||
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
|
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
|
||||||
@@ -141,6 +154,56 @@ export function AnalyticsPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>公开内容获客</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<ContentMetricCard
|
||||||
|
label="Brief 浏览"
|
||||||
|
total={briefView?.total}
|
||||||
|
uniqueActors={briefView?.unique_actors}
|
||||||
|
/>
|
||||||
|
<ContentMetricCard
|
||||||
|
label="Brief CTA"
|
||||||
|
total={briefCtaClick?.total}
|
||||||
|
uniqueActors={briefCtaClick?.unique_actors}
|
||||||
|
/>
|
||||||
|
<ContentMetricCard
|
||||||
|
label="方法论阅读"
|
||||||
|
total={methodologyView?.total}
|
||||||
|
uniqueActors={methodologyView?.unique_actors}
|
||||||
|
/>
|
||||||
|
<ContentMetricCard
|
||||||
|
label="社交外链"
|
||||||
|
total={socialOutboundClick?.total}
|
||||||
|
uniqueActors={socialOutboundClick?.unique_actors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||||
|
{[
|
||||||
|
["内容路径", content.paths ?? []],
|
||||||
|
["Brief 城市", content.cities ?? []],
|
||||||
|
].map(([title, rows]) => (
|
||||||
|
<div key={String(title)} className="space-y-2 rounded-lg border border-white/10 p-3">
|
||||||
|
<div className="text-xs font-bold text-slate-500">{String(title)}</div>
|
||||||
|
{(rows as TopItem[]).length === 0 ? (
|
||||||
|
<div className="text-xs text-slate-500">暂无数据</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{(rows as TopItem[]).slice(0, 5).map((item) => (
|
||||||
|
<li key={item.name} className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
|
||||||
|
<span className="font-mono text-blue-600">{item.count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>来源与设备</CardTitle></CardHeader>
|
<CardHeader><CardTitle>来源与设备</CardTitle></CardHeader>
|
||||||
@@ -243,3 +306,21 @@ export function AnalyticsPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContentMetricCard({
|
||||||
|
label,
|
||||||
|
total,
|
||||||
|
uniqueActors,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
total?: number;
|
||||||
|
uniqueActors?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-white/10 p-4">
|
||||||
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
|
<div className="text-xl font-bold text-white">{total ?? 0}</div>
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500">独立 {uniqueActors ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type PublicContentEvent =
|
||||||
|
| "brief_view"
|
||||||
|
| "brief_cta_click"
|
||||||
|
| "methodology_view"
|
||||||
|
| "social_outbound_click";
|
||||||
|
|
||||||
|
type AnalyticsPayload = Record<string, unknown>;
|
||||||
|
|
||||||
|
async function emitPublicContentEvent(
|
||||||
|
eventType: PublicContentEvent,
|
||||||
|
payload: AnalyticsPayload,
|
||||||
|
onceKey?: string,
|
||||||
|
) {
|
||||||
|
const { markAnalyticsOnce, trackAppEvent } = await import("@/lib/app-analytics");
|
||||||
|
if (onceKey && !markAnalyticsOnce(`public-content:${onceKey}`)) return;
|
||||||
|
trackAppEvent(eventType, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicContentAnalytics({
|
||||||
|
eventType,
|
||||||
|
onceKey,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
eventType: PublicContentEvent;
|
||||||
|
onceKey?: string;
|
||||||
|
payload: AnalyticsPayload;
|
||||||
|
}) {
|
||||||
|
const payloadKey = JSON.stringify(payload);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parsedPayload = JSON.parse(payloadKey) as AnalyticsPayload;
|
||||||
|
void emitPublicContentEvent(eventType, parsedPayload, onceKey);
|
||||||
|
}, [eventType, onceKey, payloadKey]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicContentCta({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
href,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
href: string;
|
||||||
|
payload: AnalyticsPayload;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={className}
|
||||||
|
href={href}
|
||||||
|
onClick={() => {
|
||||||
|
void emitPublicContentEvent("brief_cta_click", payload);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicContentOutboundLink({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
href,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
href: string;
|
||||||
|
payload: AnalyticsPayload;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={className}
|
||||||
|
href={href}
|
||||||
|
onClick={() => {
|
||||||
|
void emitPublicContentEvent("social_outbound_click", payload);
|
||||||
|
}}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
METHODOLOGY_PAGES,
|
||||||
|
PUBLIC_BRIEFS,
|
||||||
|
SOURCE_PAGES,
|
||||||
|
absolutePublicUrl,
|
||||||
|
briefPath,
|
||||||
|
methodologyPath,
|
||||||
|
sourcePath,
|
||||||
|
type MethodologyPage,
|
||||||
|
type PublicBrief,
|
||||||
|
type SourcePage,
|
||||||
|
} from "@/content/public-content";
|
||||||
|
import {
|
||||||
|
PublicContentAnalytics,
|
||||||
|
PublicContentCta,
|
||||||
|
PublicContentOutboundLink,
|
||||||
|
} from "./PublicContentAnalytics";
|
||||||
|
|
||||||
|
const pageShell =
|
||||||
|
"min-h-screen bg-[#f4f7fb] text-slate-950";
|
||||||
|
const contentWrap =
|
||||||
|
"mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-6 sm:px-6 lg:px-8";
|
||||||
|
const panel =
|
||||||
|
"rounded-lg border border-slate-200 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.05)]";
|
||||||
|
const sectionTitle =
|
||||||
|
"text-sm font-semibold uppercase tracking-[0.08em] text-slate-500";
|
||||||
|
const bodyText = "text-sm leading-6 text-slate-700";
|
||||||
|
const primaryButton =
|
||||||
|
"inline-flex min-h-10 items-center justify-center rounded-md bg-slate-950 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800";
|
||||||
|
const secondaryButton =
|
||||||
|
"inline-flex min-h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 hover:bg-slate-50";
|
||||||
|
|
||||||
|
function PublicHeader() {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-slate-200 bg-white/90">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
|
||||||
|
<Link className="text-base font-black text-slate-950" href="/">
|
||||||
|
PolyWeather
|
||||||
|
</Link>
|
||||||
|
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
|
||||||
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
|
||||||
|
Briefs
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
|
||||||
|
Methodology
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
|
||||||
|
Sources
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageIntro({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="grid gap-4 border-b border-slate-200 bg-white">
|
||||||
|
<div className={`${contentWrap} py-10 sm:py-12`}>
|
||||||
|
<p className={sectionTitle}>{eyebrow}</p>
|
||||||
|
<div className="max-w-3xl space-y-4">
|
||||||
|
<h1 className="text-3xl font-black leading-tight text-slate-950 sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base leading-7 text-slate-700">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceLinks({ slugs }: { slugs: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{slugs.map((slug) => {
|
||||||
|
const source = SOURCE_PAGES.find((entry) => entry.slug === slug);
|
||||||
|
if (!source) return null;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
href={sourcePath(source)}
|
||||||
|
key={slug}
|
||||||
|
>
|
||||||
|
{source.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MethodologyLinks({ slugs }: { slugs: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{slugs.map((slug) => {
|
||||||
|
const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug);
|
||||||
|
if (!page) return null;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
href={methodologyPath(page)}
|
||||||
|
key={slug}
|
||||||
|
>
|
||||||
|
{page.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BriefCard({ brief }: { brief: PublicBrief }) {
|
||||||
|
return (
|
||||||
|
<article className={`${panel} flex flex-col gap-5 p-5`}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-blue-700">
|
||||||
|
{brief.cityName} / {brief.date}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-xl font-black text-slate-950">
|
||||||
|
<Link href={briefPath(brief)}>{brief.title}</Link>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-md bg-emerald-50 px-2.5 py-1 text-xs font-bold text-emerald-800">
|
||||||
|
{brief.settlementSource}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={bodyText}>{brief.description}</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{brief.signals.map((signal) => (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-3" key={signal.label}>
|
||||||
|
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-bold text-slate-950">{signal.value}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-600">{signal.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link className={secondaryButton} href={briefPath(brief)}>
|
||||||
|
Read brief
|
||||||
|
</Link>
|
||||||
|
<SourceLinks slugs={brief.sourceSlugs.slice(0, 2)} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BriefsIndexPageView() {
|
||||||
|
return (
|
||||||
|
<div className={pageShell}>
|
||||||
|
<PublicHeader />
|
||||||
|
<PageIntro
|
||||||
|
description="Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer."
|
||||||
|
eyebrow="Weather Market Brief"
|
||||||
|
title="Public market briefs for temperature judgment"
|
||||||
|
/>
|
||||||
|
<div className={contentWrap}>
|
||||||
|
<section className="grid gap-4">
|
||||||
|
{PUBLIC_BRIEFS.map((brief) => (
|
||||||
|
<BriefCard brief={brief} key={`${brief.city}-${brief.date}`} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Methodology</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||||
|
How the public read is produced
|
||||||
|
</h2>
|
||||||
|
<p className={`${bodyText} mt-3`}>
|
||||||
|
Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<MethodologyLinks slugs={["deb", "settlement-sources"]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Source notes</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||||
|
Official-source context
|
||||||
|
</h2>
|
||||||
|
<p className={`${bodyText} mt-3`}>
|
||||||
|
Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<SourceLinks slugs={["mgm", "metar", "hko", "noaa"]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||||
|
return (
|
||||||
|
<div className={pageShell}>
|
||||||
|
<PublicContentAnalytics
|
||||||
|
eventType="brief_view"
|
||||||
|
onceKey={`brief:${brief.city}:${brief.date}`}
|
||||||
|
payload={{ city: brief.city, date: brief.date, source: brief.settlementSource }}
|
||||||
|
/>
|
||||||
|
<PublicHeader />
|
||||||
|
<PageIntro
|
||||||
|
description={brief.description}
|
||||||
|
eyebrow={`${brief.cityName}, ${brief.countryName} / ${brief.date}`}
|
||||||
|
title={brief.title}
|
||||||
|
/>
|
||||||
|
<div className={contentWrap}>
|
||||||
|
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.9fr]">
|
||||||
|
<article className={`${panel} p-5`}>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{brief.signals.map((signal) => (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-4" key={signal.label}>
|
||||||
|
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
|
||||||
|
<p className="mt-1 font-mono text-2xl font-black text-slate-950">{signal.value}</p>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-slate-600">{signal.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-5">
|
||||||
|
<BriefSection title="DEB read" body={brief.debRead} />
|
||||||
|
<BriefSection title="Settlement-source read" body={brief.sourceRead} />
|
||||||
|
<BriefSection title="Model context" body={brief.modelRead} />
|
||||||
|
<BriefSection title="Risk notes" body={brief.riskRead} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<aside className={`${panel} h-fit p-5`}>
|
||||||
|
<p className={sectionTitle}>Snapshot</p>
|
||||||
|
<dl className="mt-4 grid gap-3 text-sm">
|
||||||
|
<InfoRow label="Market" value={brief.market} />
|
||||||
|
<InfoRow label="Settlement source" value={brief.settlementSource} />
|
||||||
|
<InfoRow label="Updated" value={formatDateTime(brief.updatedAt)} />
|
||||||
|
<InfoRow label="Freshness" value={brief.dataFreshness} />
|
||||||
|
</dl>
|
||||||
|
<div className="mt-5 flex flex-col gap-3">
|
||||||
|
<PublicContentCta
|
||||||
|
className={primaryButton}
|
||||||
|
href="/terminal"
|
||||||
|
payload={{ city: brief.city, date: brief.date, cta: "terminal" }}
|
||||||
|
>
|
||||||
|
{brief.primaryCtaLabel}
|
||||||
|
</PublicContentCta>
|
||||||
|
<Link className={secondaryButton} href="/briefs">
|
||||||
|
All public briefs
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
||||||
|
<div className={`${panel} p-5`}>
|
||||||
|
<p className={sectionTitle}>Checks before acting</p>
|
||||||
|
<ul className="mt-4 grid gap-3">
|
||||||
|
{brief.checkpoints.map((checkpoint) => (
|
||||||
|
<li className={bodyText} key={checkpoint}>
|
||||||
|
{checkpoint}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className={`${panel} p-5`}>
|
||||||
|
<p className={sectionTitle}>Distribution copy</p>
|
||||||
|
<p className={`${bodyText} mt-4`}>{brief.distributionText}</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<PublicContentOutboundLink
|
||||||
|
className={secondaryButton}
|
||||||
|
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(brief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(brief)))}`}
|
||||||
|
payload={{ city: brief.city, date: brief.date, destination: "x_intent" }}
|
||||||
|
>
|
||||||
|
Share on X
|
||||||
|
</PublicContentOutboundLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Methodology links</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<MethodologyLinks slugs={brief.methodologySlugs} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Source links</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<SourceLinks slugs={brief.sourceSlugs} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
|
||||||
|
{brief.notFinancialAdvice}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BriefSection({ body, title }: { body: string; title: string }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-black text-slate-950">{title}</h2>
|
||||||
|
<p className={`${bodyText} mt-2`}>{body}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1 border-b border-slate-100 pb-3 last:border-b-0 last:pb-0">
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-[0.08em] text-slate-500">{label}</dt>
|
||||||
|
<dd className="leading-6 text-slate-800">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MethodologyIndexPageView() {
|
||||||
|
return (
|
||||||
|
<div className={pageShell}>
|
||||||
|
<PublicHeader />
|
||||||
|
<PageIntro
|
||||||
|
description="Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis."
|
||||||
|
eyebrow="Methodology"
|
||||||
|
title="How PolyWeather reads weather markets"
|
||||||
|
/>
|
||||||
|
<div className={contentWrap}>
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
{METHODOLOGY_PAGES.map((page) => (
|
||||||
|
<article className={`${panel} p-5`} key={page.slug}>
|
||||||
|
<p className={sectionTitle}>{formatDate(page.updatedAt)}</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||||
|
<Link href={methodologyPath(page)}>{page.title}</Link>
|
||||||
|
</h2>
|
||||||
|
<p className={`${bodyText} mt-3`}>{page.description}</p>
|
||||||
|
<Link className={`${secondaryButton} mt-5`} href={methodologyPath(page)}>
|
||||||
|
Read methodology
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
|
||||||
|
return (
|
||||||
|
<div className={pageShell}>
|
||||||
|
<PublicContentAnalytics
|
||||||
|
eventType="methodology_view"
|
||||||
|
onceKey={`methodology:${page.slug}`}
|
||||||
|
payload={{ slug: page.slug, content_type: "methodology" }}
|
||||||
|
/>
|
||||||
|
<PublicHeader />
|
||||||
|
<PageIntro description={page.description} eyebrow="Methodology" title={page.title} />
|
||||||
|
<div className={contentWrap}>
|
||||||
|
<article className={`${panel} p-5`}>
|
||||||
|
<p className="max-w-3xl text-base leading-7 text-slate-700">{page.summary}</p>
|
||||||
|
<div className="mt-8 grid gap-8">
|
||||||
|
{page.sections.map((section) => (
|
||||||
|
<section key={section.heading}>
|
||||||
|
<h2 className="text-xl font-black text-slate-950">{section.heading}</h2>
|
||||||
|
<p className={`${bodyText} mt-3`}>{section.body}</p>
|
||||||
|
<ul className="mt-4 grid gap-3">
|
||||||
|
{section.bullets.map((bullet) => (
|
||||||
|
<li className={bodyText} key={bullet}>
|
||||||
|
{bullet}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesIndexPageView() {
|
||||||
|
return (
|
||||||
|
<div className={pageShell}>
|
||||||
|
<PublicHeader />
|
||||||
|
<PageIntro
|
||||||
|
description="Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence."
|
||||||
|
eyebrow="Sources"
|
||||||
|
title="Weather source notes for public audit"
|
||||||
|
/>
|
||||||
|
<div className={contentWrap}>
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
{SOURCE_PAGES.map((source) => (
|
||||||
|
<article className={`${panel} p-5`} key={source.slug}>
|
||||||
|
<p className={sectionTitle}>{source.operator}</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||||
|
<Link href={sourcePath(source)}>{source.title}</Link>
|
||||||
|
</h2>
|
||||||
|
<p className={`${bodyText} mt-3`}>{source.description}</p>
|
||||||
|
<Link className={`${secondaryButton} mt-5`} href={sourcePath(source)}>
|
||||||
|
Read source note
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceDetailPageView({ source }: { source: SourcePage }) {
|
||||||
|
return (
|
||||||
|
<div className={pageShell}>
|
||||||
|
<PublicHeader />
|
||||||
|
<PageIntro description={source.description} eyebrow="Source note" title={source.title} />
|
||||||
|
<div className={contentWrap}>
|
||||||
|
<article className={`${panel} grid gap-6 p-5 lg:grid-cols-[0.9fr_1.4fr]`}>
|
||||||
|
<dl className="grid gap-3 text-sm">
|
||||||
|
<InfoRow label="Operator" value={source.operator} />
|
||||||
|
<InfoRow label="Coverage" value={source.coverage} />
|
||||||
|
<InfoRow label="Cadence" value={source.cadence} />
|
||||||
|
<InfoRow label="Settlement use" value={source.settlementUse} />
|
||||||
|
</dl>
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Reliability notes</p>
|
||||||
|
<ul className="mt-4 grid gap-3">
|
||||||
|
{source.reliabilityNotes.map((note) => (
|
||||||
|
<li className={bodyText} key={note}>
|
||||||
|
{note}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className={sectionTitle}>Related methodology</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<MethodologyLinks slugs={source.relatedMethodologySlugs} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("en", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("en", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const root = process.cwd();
|
||||||
|
const contentPath = path.join(root, "content", "public-content.ts");
|
||||||
|
const briefsIndexPath = path.join(root, "app", "briefs", "page.tsx");
|
||||||
|
const briefDetailPath = path.join(root, "app", "briefs", "[city]", "[date]", "page.tsx");
|
||||||
|
const methodologyIndexPath = path.join(root, "app", "methodology", "page.tsx");
|
||||||
|
const methodologyDetailPath = path.join(root, "app", "methodology", "[slug]", "page.tsx");
|
||||||
|
const sourcesIndexPath = path.join(root, "app", "sources", "page.tsx");
|
||||||
|
const sourceDetailPath = path.join(root, "app", "sources", "[slug]", "page.tsx");
|
||||||
|
const analyticsPath = path.join(root, "lib", "app-analytics.ts");
|
||||||
|
const analyticsIslandPath = path.join(root, "components", "public-content", "PublicContentAnalytics.tsx");
|
||||||
|
const publicPagesPath = path.join(root, "components", "public-content", "PublicContentPages.tsx");
|
||||||
|
const sitemapPath = path.join(root, "app", "sitemap.ts");
|
||||||
|
|
||||||
|
for (const requiredPath of [
|
||||||
|
contentPath,
|
||||||
|
briefsIndexPath,
|
||||||
|
briefDetailPath,
|
||||||
|
methodologyIndexPath,
|
||||||
|
methodologyDetailPath,
|
||||||
|
sourcesIndexPath,
|
||||||
|
sourceDetailPath,
|
||||||
|
analyticsIslandPath,
|
||||||
|
publicPagesPath,
|
||||||
|
]) {
|
||||||
|
assert(fs.existsSync(requiredPath), `${path.relative(root, requiredPath)} must exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(contentPath, "utf8");
|
||||||
|
const briefDetail = fs.readFileSync(briefDetailPath, "utf8");
|
||||||
|
const methodologyDetail = fs.readFileSync(methodologyDetailPath, "utf8");
|
||||||
|
const sourceDetail = fs.readFileSync(sourceDetailPath, "utf8");
|
||||||
|
const analytics = fs.readFileSync(analyticsPath, "utf8");
|
||||||
|
const analyticsIsland = fs.readFileSync(analyticsIslandPath, "utf8");
|
||||||
|
const publicPages = fs.readFileSync(publicPagesPath, "utf8");
|
||||||
|
const sitemap = fs.readFileSync(sitemapPath, "utf8");
|
||||||
|
const briefsIndex = fs.readFileSync(briefsIndexPath, "utf8");
|
||||||
|
|
||||||
|
assert(
|
||||||
|
content.includes("PUBLIC_BRIEFS") &&
|
||||||
|
content.includes("METHODOLOGY_PAGES") &&
|
||||||
|
content.includes("SOURCE_PAGES") &&
|
||||||
|
content.includes('"ankara"') &&
|
||||||
|
content.includes('"deb"') &&
|
||||||
|
content.includes('"mgm"'),
|
||||||
|
"public content module must define sample briefs plus DEB and MGM public pages",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
content.includes("notFinancialAdvice") &&
|
||||||
|
content.includes("updatedAt") &&
|
||||||
|
content.includes("settlementSource") &&
|
||||||
|
content.includes("distributionText"),
|
||||||
|
"public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
briefDetail.includes("generateStaticParams") &&
|
||||||
|
briefDetail.includes("generateMetadata") &&
|
||||||
|
briefDetail.includes("application/ld+json") &&
|
||||||
|
briefDetail.includes("BreadcrumbList") &&
|
||||||
|
briefDetail.includes("Article") &&
|
||||||
|
briefDetail.includes("notFound()"),
|
||||||
|
"brief detail route must be statically indexable with metadata, JSON-LD, breadcrumbs, and 404 handling",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
methodologyDetail.includes("generateStaticParams") &&
|
||||||
|
methodologyDetail.includes("TechArticle") &&
|
||||||
|
methodologyDetail.includes("application/ld+json") &&
|
||||||
|
sourceDetail.includes("Dataset") &&
|
||||||
|
sourceDetail.includes("application/ld+json"),
|
||||||
|
"methodology and source detail routes must expose structured data for GEO/SEO",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
publicPages.includes('MethodologyLinks slugs={["deb", "settlement-sources"]}') &&
|
||||||
|
publicPages.includes('SourceLinks slugs={["mgm", "metar", "hko", "noaa"]}') &&
|
||||||
|
briefsIndex.includes("Weather Market Brief"),
|
||||||
|
"brief index must cross-link to DEB methodology and settlement source pages",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
analytics.includes('"brief_view"') &&
|
||||||
|
analytics.includes('"brief_cta_click"') &&
|
||||||
|
analytics.includes('"methodology_view"') &&
|
||||||
|
analytics.includes('"social_outbound_click"'),
|
||||||
|
"analytics event union must include public content acquisition events",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
analyticsIsland.startsWith('"use client"') &&
|
||||||
|
analyticsIsland.includes("trackAppEvent") &&
|
||||||
|
analyticsIsland.includes("brief_view") &&
|
||||||
|
analyticsIsland.includes("brief_cta_click") &&
|
||||||
|
analyticsIsland.includes("methodology_view") &&
|
||||||
|
analyticsIsland.includes("social_outbound_click"),
|
||||||
|
"public content analytics must be isolated in a client island and emit the new events",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sitemap.includes("PUBLIC_BRIEFS") &&
|
||||||
|
sitemap.includes("METHODOLOGY_PAGES") &&
|
||||||
|
sitemap.includes("SOURCE_PAGES") &&
|
||||||
|
sitemap.includes("/briefs") &&
|
||||||
|
sitemap.includes("/methodology/") &&
|
||||||
|
sitemap.includes("/sources/"),
|
||||||
|
"sitemap must enumerate public content assets for search and answer engines",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top";
|
||||||
|
|
||||||
|
export type PublicBriefSignal = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicBrief = {
|
||||||
|
city: string;
|
||||||
|
cityName: string;
|
||||||
|
countryName: string;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
market: string;
|
||||||
|
settlementSource: string;
|
||||||
|
updatedAt: string;
|
||||||
|
publishedAt: string;
|
||||||
|
dataFreshness: string;
|
||||||
|
debRead: string;
|
||||||
|
sourceRead: string;
|
||||||
|
modelRead: string;
|
||||||
|
riskRead: string;
|
||||||
|
notFinancialAdvice: string;
|
||||||
|
distributionText: string;
|
||||||
|
primaryCtaLabel: string;
|
||||||
|
sourceSlugs: string[];
|
||||||
|
methodologySlugs: string[];
|
||||||
|
signals: PublicBriefSignal[];
|
||||||
|
checkpoints: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MethodologyPage = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
updatedAt: string;
|
||||||
|
summary: string;
|
||||||
|
sections: Array<{
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
bullets: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SourcePage = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
updatedAt: string;
|
||||||
|
operator: string;
|
||||||
|
coverage: string;
|
||||||
|
cadence: string;
|
||||||
|
settlementUse: string;
|
||||||
|
reliabilityNotes: string[];
|
||||||
|
relatedMethodologySlugs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUBLIC_BRIEFS: PublicBrief[] = [
|
||||||
|
{
|
||||||
|
city: "ankara",
|
||||||
|
cityName: "Ankara",
|
||||||
|
countryName: "Turkey",
|
||||||
|
date: "2026-06-24",
|
||||||
|
title: "Ankara Weather Market Brief - 24 Jun 2026",
|
||||||
|
description:
|
||||||
|
"A public market brief for Ankara maximum temperature judgment, focused on MGM settlement-source behavior, DEB blended forecast context, and anomaly checks.",
|
||||||
|
market: "Same-day maximum temperature judgment",
|
||||||
|
settlementSource: "MGM official station",
|
||||||
|
updatedAt: "2026-06-24T13:55:00+03:00",
|
||||||
|
publishedAt: "2026-06-24T13:55:00+03:00",
|
||||||
|
dataFreshness:
|
||||||
|
"Static public snapshot. Paid terminal users should verify the latest official observation and SSE replay state before acting.",
|
||||||
|
debRead:
|
||||||
|
"DEB kept the intraday high-temperature read below the isolated MGM spike and closer to the observed official range.",
|
||||||
|
sourceRead:
|
||||||
|
"MGM is treated as the primary settlement reference. A single 27.1 C point should be checked against adjacent official readings before it is accepted as a new high.",
|
||||||
|
modelRead:
|
||||||
|
"ECMWF was warmer than the DEB blend in the early afternoon window, but the public brief weights official observations above model-only movement.",
|
||||||
|
riskRead:
|
||||||
|
"Main risk is a late official update or a source-side correction that changes the recognized high after the public snapshot.",
|
||||||
|
notFinancialAdvice:
|
||||||
|
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
|
||||||
|
distributionText:
|
||||||
|
"Ankara 2026-06-24 public Weather Market Brief: MGM official readings favored a 24.5 C observed high over an isolated 27.1 C spike; DEB stayed below the outlier. Not financial advice.",
|
||||||
|
primaryCtaLabel: "Open live terminal",
|
||||||
|
sourceSlugs: ["mgm", "metar", "ecmwf"],
|
||||||
|
methodologySlugs: ["deb", "settlement-sources"],
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
label: "Observed high so far",
|
||||||
|
value: "24.5 C",
|
||||||
|
detail: "Official-source value to compare against any isolated higher point.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Outlier under review",
|
||||||
|
value: "27.1 C",
|
||||||
|
detail: "A sudden single-source value needs neighboring-time validation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "DEB public read",
|
||||||
|
value: "Below spike",
|
||||||
|
detail: "Blend stayed closer to the verified observation band.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkpoints: [
|
||||||
|
"Check whether the suspected spike appears in the official high-temperature summary.",
|
||||||
|
"Compare adjacent MGM observations before treating a single point as settlement-relevant.",
|
||||||
|
"Review the paid terminal for live chart patches and source freshness before market close.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
city: "hong-kong",
|
||||||
|
cityName: "Hong Kong",
|
||||||
|
countryName: "Hong Kong",
|
||||||
|
date: "2026-06-24",
|
||||||
|
title: "Hong Kong Weather Market Brief - 24 Jun 2026",
|
||||||
|
description:
|
||||||
|
"A public brief showing how PolyWeather frames HKO/Cheung Chau/airport observations against DEB and model spread for maximum-temperature markets.",
|
||||||
|
market: "Urban and airport maximum temperature judgment",
|
||||||
|
settlementSource: "HKO official network",
|
||||||
|
updatedAt: "2026-06-24T18:00:00+08:00",
|
||||||
|
publishedAt: "2026-06-24T18:00:00+08:00",
|
||||||
|
dataFreshness:
|
||||||
|
"Static public snapshot. Live terminal values may differ as HKO and station caches refresh.",
|
||||||
|
debRead:
|
||||||
|
"DEB favored a narrow high-temperature window because live observations and model spread were aligned by late afternoon.",
|
||||||
|
sourceRead:
|
||||||
|
"HKO network observations remain the source family to reconcile before interpreting airport-only movement.",
|
||||||
|
modelRead:
|
||||||
|
"Model disagreement was limited, so source freshness and station selection mattered more than broad synoptic uncertainty.",
|
||||||
|
riskRead:
|
||||||
|
"Primary risk is station-specific heat retention during late afternoon or a late official summary revision.",
|
||||||
|
notFinancialAdvice:
|
||||||
|
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
|
||||||
|
distributionText:
|
||||||
|
"Hong Kong 2026-06-24 public Weather Market Brief: source selection and HKO freshness mattered more than model spread. Not financial advice.",
|
||||||
|
primaryCtaLabel: "Open live terminal",
|
||||||
|
sourceSlugs: ["hko", "metar"],
|
||||||
|
methodologySlugs: ["deb", "settlement-sources"],
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
label: "Source family",
|
||||||
|
value: "HKO",
|
||||||
|
detail: "Use official station context before airport-only interpretation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Model spread",
|
||||||
|
value: "Low",
|
||||||
|
detail: "Late-day uncertainty mainly came from station behavior.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Terminal need",
|
||||||
|
value: "Freshness",
|
||||||
|
detail: "Live source timestamps decide whether the public snapshot is still useful.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkpoints: [
|
||||||
|
"Verify HKO station timestamps before comparing market bands.",
|
||||||
|
"Separate airport METAR observations from settlement-source network readings.",
|
||||||
|
"Check terminal source health if the public snapshot is older than one refresh cycle.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
city: "new-york",
|
||||||
|
cityName: "New York",
|
||||||
|
countryName: "United States",
|
||||||
|
date: "2026-06-24",
|
||||||
|
title: "New York Weather Market Brief - 24 Jun 2026",
|
||||||
|
description:
|
||||||
|
"A public brief for New York temperature markets, connecting METAR, NOAA context, DEB blending, and late-day risk checks.",
|
||||||
|
market: "Airport-linked maximum temperature judgment",
|
||||||
|
settlementSource: "METAR and NOAA station context",
|
||||||
|
updatedAt: "2026-06-24T12:30:00-04:00",
|
||||||
|
publishedAt: "2026-06-24T12:30:00-04:00",
|
||||||
|
dataFreshness:
|
||||||
|
"Static public snapshot. Paid terminal users should check current METAR observations and official summaries.",
|
||||||
|
debRead:
|
||||||
|
"DEB weighted the latest observation trend against warmer model guidance instead of following the raw model high.",
|
||||||
|
sourceRead:
|
||||||
|
"METAR provides fast airport evidence, while NOAA context helps validate whether the airport value is representative.",
|
||||||
|
modelRead:
|
||||||
|
"Warm model guidance can be useful only after it is reconciled with live airport observations and cloud/wind context.",
|
||||||
|
riskRead:
|
||||||
|
"Primary risk is a short late-day break in cloud cover that lifts airport observations into a higher band.",
|
||||||
|
notFinancialAdvice:
|
||||||
|
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
|
||||||
|
distributionText:
|
||||||
|
"New York 2026-06-24 public Weather Market Brief: DEB blended warmer guidance against live airport evidence. Not financial advice.",
|
||||||
|
primaryCtaLabel: "Open live terminal",
|
||||||
|
sourceSlugs: ["metar", "noaa"],
|
||||||
|
methodologySlugs: ["deb", "settlement-sources"],
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
label: "Fast evidence",
|
||||||
|
value: "METAR",
|
||||||
|
detail: "Airport observations define the short-cycle read.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Validation",
|
||||||
|
value: "NOAA",
|
||||||
|
detail: "Official context helps audit the final high-temperature interpretation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "DEB stance",
|
||||||
|
value: "Blend",
|
||||||
|
detail: "Do not follow a warm model run without live evidence confirmation.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkpoints: [
|
||||||
|
"Watch the last two METAR cycles before the daily high window ends.",
|
||||||
|
"Check whether model warmth is supported by cloud and wind observations.",
|
||||||
|
"Use official source context before final settlement interpretation.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const METHODOLOGY_PAGES: MethodologyPage[] = [
|
||||||
|
{
|
||||||
|
slug: "deb",
|
||||||
|
title: "DEB Forecast Methodology",
|
||||||
|
description:
|
||||||
|
"How PolyWeather frames DEB blended forecasts for prediction-market temperature decisions without replacing settlement-source evidence.",
|
||||||
|
updatedAt: "2026-06-24T00:00:00Z",
|
||||||
|
summary:
|
||||||
|
"DEB is the public name for PolyWeather's blended forecast layer. It reconciles model guidance, live observation momentum, source freshness, and station context so users can judge a high-temperature band with fewer single-model mistakes.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "What DEB is for",
|
||||||
|
body:
|
||||||
|
"DEB is not a settlement oracle. It is a decision-support layer that makes the live observation path and model spread easier to compare.",
|
||||||
|
bullets: [
|
||||||
|
"Prefer settlement-source evidence when it conflicts with model-only guidance.",
|
||||||
|
"Treat stale or isolated source values as quality-control candidates.",
|
||||||
|
"Expose the forecast band and the reason a band is widening or narrowing.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Inputs that matter",
|
||||||
|
body:
|
||||||
|
"The blend is useful because it combines different evidence classes instead of pretending one model run is enough.",
|
||||||
|
bullets: [
|
||||||
|
"Latest official or airport observations and their freshness.",
|
||||||
|
"Model consensus and disagreement across ECMWF, GFS, ICON, GEM, and local sources when available.",
|
||||||
|
"City-specific source behavior, station selection, and intraday high timing.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "How to read a DEB miss",
|
||||||
|
body:
|
||||||
|
"A DEB miss should be reviewed by separating source freshness, model spread, and settlement-source revisions.",
|
||||||
|
bullets: [
|
||||||
|
"If the observed high changed after a late source patch, classify it as a freshness or replay issue.",
|
||||||
|
"If every source was fresh but the high landed outside the band, review model weighting and local station features.",
|
||||||
|
"If one source jumped alone, audit neighboring observations before retraining around the outlier.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "settlement-sources",
|
||||||
|
title: "Settlement-Source Priority",
|
||||||
|
description:
|
||||||
|
"Why PolyWeather presents official settlement-related observations before generic weather API values.",
|
||||||
|
updatedAt: "2026-06-24T00:00:00Z",
|
||||||
|
summary:
|
||||||
|
"Prediction-market users care about the number that resolves the contract. PolyWeather therefore puts official station, airport, and operator-specific source behavior above broad consumer-weather averages.",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: "Why generic weather values are not enough",
|
||||||
|
body:
|
||||||
|
"Consumer weather apps often smooth station data or display city-wide approximations. Market resolution can depend on a narrower official source.",
|
||||||
|
bullets: [
|
||||||
|
"A city label may hide multiple stations with different daily highs.",
|
||||||
|
"Airport METAR can update faster than a public summary, but may not be the final settlement source.",
|
||||||
|
"Official source revisions can matter more than a visually smooth forecast curve.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "What PolyWeather surfaces",
|
||||||
|
body:
|
||||||
|
"The terminal separates source labels, observation timestamps, forecast models, and freshness state so a user can audit the path to a number.",
|
||||||
|
bullets: [
|
||||||
|
"Settlement-source labels and station context on charts.",
|
||||||
|
"Source freshness, cache policy, and SSE patch visibility.",
|
||||||
|
"DEB forecast context shown next to live observations, not as a replacement for them.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SOURCE_PAGES: SourcePage[] = [
|
||||||
|
{
|
||||||
|
slug: "mgm",
|
||||||
|
title: "MGM Weather Source",
|
||||||
|
description:
|
||||||
|
"PolyWeather source note for Turkish MGM observations used in Ankara-style temperature market analysis.",
|
||||||
|
updatedAt: "2026-06-24T00:00:00Z",
|
||||||
|
operator: "Turkish State Meteorological Service",
|
||||||
|
coverage: "Turkey official station network, including Ankara market context.",
|
||||||
|
cadence: "Source cadence varies by station and publication path; terminal freshness checks are required.",
|
||||||
|
settlementUse:
|
||||||
|
"Used as the primary official-source family when Ankara markets reference Turkish official observations.",
|
||||||
|
reliabilityNotes: [
|
||||||
|
"Single-point spikes should be compared with neighboring timestamps before being accepted.",
|
||||||
|
"Official summaries can lag raw point observations.",
|
||||||
|
"Cache and SSE replay state should be checked when a value appears suddenly.",
|
||||||
|
],
|
||||||
|
relatedMethodologySlugs: ["settlement-sources", "deb"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "metar",
|
||||||
|
title: "METAR Airport Observations",
|
||||||
|
description:
|
||||||
|
"PolyWeather source note for airport METAR observations used as fast evidence in temperature-market workflows.",
|
||||||
|
updatedAt: "2026-06-24T00:00:00Z",
|
||||||
|
operator: "Airport weather observation network",
|
||||||
|
coverage: "Airport-linked observations across supported markets.",
|
||||||
|
cadence: "Often hourly or sub-hourly depending on airport and issuance behavior.",
|
||||||
|
settlementUse:
|
||||||
|
"Useful for fast evidence and airport-linked contracts; must be reconciled with the contract's exact settlement source.",
|
||||||
|
reliabilityNotes: [
|
||||||
|
"METAR can update faster than official daily summaries.",
|
||||||
|
"Airport exposure may differ from city-center official stations.",
|
||||||
|
"Late METAR cycles can change high-temperature judgment near market close.",
|
||||||
|
],
|
||||||
|
relatedMethodologySlugs: ["settlement-sources", "deb"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "ecmwf",
|
||||||
|
title: "ECMWF Model Guidance",
|
||||||
|
description:
|
||||||
|
"PolyWeather source note for ECMWF model guidance as one input into DEB blended forecasts.",
|
||||||
|
updatedAt: "2026-06-24T00:00:00Z",
|
||||||
|
operator: "European Centre for Medium-Range Weather Forecasts",
|
||||||
|
coverage: "Global numerical weather prediction guidance.",
|
||||||
|
cadence: "Model-run cadence depends on product and ingestion timing.",
|
||||||
|
settlementUse:
|
||||||
|
"Used for forecast context only. It does not replace live official observations for settlement interpretation.",
|
||||||
|
reliabilityNotes: [
|
||||||
|
"Model warmth or coolness should be validated against live observations.",
|
||||||
|
"Run-to-run shifts can be useful when source evidence has not yet settled.",
|
||||||
|
"Model spread should be shown beside, not above, settlement-source data.",
|
||||||
|
],
|
||||||
|
relatedMethodologySlugs: ["deb"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "hko",
|
||||||
|
title: "HKO Official Observations",
|
||||||
|
description:
|
||||||
|
"PolyWeather source note for Hong Kong Observatory observations in Hong Kong market analysis.",
|
||||||
|
updatedAt: "2026-06-24T00:00:00Z",
|
||||||
|
operator: "Hong Kong Observatory",
|
||||||
|
coverage: "Hong Kong official observation network and station-specific context.",
|
||||||
|
cadence: "Cadence varies by observation product; terminal freshness checks remain required.",
|
||||||
|
settlementUse:
|
||||||
|
"Used as the official-source family for Hong Kong station and city-market interpretation.",
|
||||||
|
reliabilityNotes: [
|
||||||
|
"Station selection can materially change the maximum-temperature read.",
|
||||||
|
"Airport observations should be separated from broader HKO network readings.",
|
||||||
|
"Humidity, wind, and late-day sun breaks can affect final highs.",
|
||||||
|
],
|
||||||
|
relatedMethodologySlugs: ["settlement-sources", "deb"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "noaa",
|
||||||
|
title: "NOAA Weather Context",
|
||||||
|
description:
|
||||||
|
"PolyWeather source note for NOAA context used to validate US weather-market observations and summaries.",
|
||||||
|
updatedAt: "2026-06-24T00:00:00Z",
|
||||||
|
operator: "National Oceanic and Atmospheric Administration",
|
||||||
|
coverage: "United States official weather observations, summaries, and context products.",
|
||||||
|
cadence: "Cadence depends on product family and station reporting behavior.",
|
||||||
|
settlementUse:
|
||||||
|
"Used to audit and contextualize US official observations where contract rules reference NOAA/NWS data.",
|
||||||
|
reliabilityNotes: [
|
||||||
|
"Official summaries can arrive after fast airport observations.",
|
||||||
|
"Daily high interpretation should match the contract's station and time zone rules.",
|
||||||
|
"Use NOAA context to confirm whether an airport observation is representative.",
|
||||||
|
],
|
||||||
|
relatedMethodologySlugs: ["settlement-sources", "deb"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function briefPath(brief: PublicBrief) {
|
||||||
|
return `/briefs/${brief.city}/${brief.date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function methodologyPath(page: MethodologyPage) {
|
||||||
|
return `/methodology/${page.slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourcePath(page: SourcePage) {
|
||||||
|
return `/sources/${page.slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrief(city: string, date: string) {
|
||||||
|
return PUBLIC_BRIEFS.find((brief) => brief.city === city && brief.date === date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMethodologyPage(slug: string) {
|
||||||
|
return METHODOLOGY_PAGES.find((page) => page.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSourcePage(slug: string) {
|
||||||
|
return SOURCE_PAGES.find((page) => page.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absolutePublicUrl(pathname: string) {
|
||||||
|
return `${PUBLIC_CONTENT_BASE_URL}${pathname}`;
|
||||||
|
}
|
||||||
@@ -17,7 +17,11 @@ type TrackableAnalyticsEvent =
|
|||||||
| "paywall_feature_clicked"
|
| "paywall_feature_clicked"
|
||||||
| "paywall_viewed"
|
| "paywall_viewed"
|
||||||
| "checkout_started"
|
| "checkout_started"
|
||||||
| "checkout_succeeded";
|
| "checkout_succeeded"
|
||||||
|
| "brief_view"
|
||||||
|
| "brief_cta_click"
|
||||||
|
| "methodology_view"
|
||||||
|
| "social_outbound_click";
|
||||||
|
|
||||||
const CLIENT_ID_KEY = "polyweather:analytics:client-id";
|
const CLIENT_ID_KEY = "polyweather:analytics:client-id";
|
||||||
const SESSION_ID_KEY = "polyweather:analytics:session-id";
|
const SESSION_ID_KEY = "polyweather:analytics:session-id";
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export const opsApi = {
|
|||||||
async funnel(days = 30) {
|
async funnel(days = 30) {
|
||||||
const raw = await opsFetch<{
|
const raw = await opsFetch<{
|
||||||
events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
|
events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
|
||||||
|
content_events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
|
||||||
|
content?: {
|
||||||
|
paths?: { name: string; count: number }[];
|
||||||
|
cities?: { name: string; count: number }[];
|
||||||
|
};
|
||||||
diagnostics?: Record<string, { total?: number; unique_actors?: number; by_reason?: { name: string; count: number }[] }>;
|
diagnostics?: Record<string, { total?: number; unique_actors?: number; by_reason?: { name: string; count: number }[] }>;
|
||||||
rates?: Record<string, number>;
|
rates?: Record<string, number>;
|
||||||
traffic?: {
|
traffic?: {
|
||||||
@@ -68,6 +73,8 @@ export const opsApi = {
|
|||||||
return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev };
|
return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev };
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
content: raw?.content ?? {},
|
||||||
|
contentEvents: raw?.content_events ?? {},
|
||||||
diagnostics: raw?.diagnostics ?? {},
|
diagnostics: raw?.diagnostics ?? {},
|
||||||
rates: raw?.rates,
|
rates: raw?.rates,
|
||||||
steps,
|
steps,
|
||||||
|
|||||||
@@ -2672,6 +2672,12 @@ class DBManager:
|
|||||||
"payment_start",
|
"payment_start",
|
||||||
"payment_success",
|
"payment_success",
|
||||||
]
|
]
|
||||||
|
content_event_names = [
|
||||||
|
"brief_view",
|
||||||
|
"brief_cta_click",
|
||||||
|
"methodology_view",
|
||||||
|
"social_outbound_click",
|
||||||
|
]
|
||||||
diagnostic_event_names = ["degraded_auth_profile"]
|
diagnostic_event_names = ["degraded_auth_profile"]
|
||||||
event_aliases = {
|
event_aliases = {
|
||||||
"landing_view": ("landing_view",),
|
"landing_view": ("landing_view",),
|
||||||
@@ -2697,6 +2703,20 @@ class DBManager:
|
|||||||
}
|
}
|
||||||
actor_sets: Dict[str, set[str]] = {name: set() for name in event_names}
|
actor_sets: Dict[str, set[str]] = {name: set() for name in event_names}
|
||||||
user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
|
user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
|
||||||
|
content_summary: Dict[str, Dict[str, Any]] = {
|
||||||
|
name: {
|
||||||
|
"total": 0,
|
||||||
|
"unique_users": 0,
|
||||||
|
"unique_actors": 0,
|
||||||
|
}
|
||||||
|
for name in content_event_names
|
||||||
|
}
|
||||||
|
content_actor_sets: Dict[str, set[str]] = {
|
||||||
|
name: set() for name in content_event_names
|
||||||
|
}
|
||||||
|
content_user_sets: Dict[str, set[str]] = {
|
||||||
|
name: set() for name in content_event_names
|
||||||
|
}
|
||||||
diagnostics: Dict[str, Dict[str, Any]] = {
|
diagnostics: Dict[str, Dict[str, Any]] = {
|
||||||
name: {"total": 0, "unique_actors": 0, "by_reason": []}
|
name: {"total": 0, "unique_actors": 0, "by_reason": []}
|
||||||
for name in diagnostic_event_names
|
for name in diagnostic_event_names
|
||||||
@@ -2711,6 +2731,8 @@ class DBManager:
|
|||||||
country_counts: Counter = Counter()
|
country_counts: Counter = Counter()
|
||||||
device_counts: Counter = Counter()
|
device_counts: Counter = Counter()
|
||||||
landing_path_counts: Counter = Counter()
|
landing_path_counts: Counter = Counter()
|
||||||
|
content_path_counts: Counter = Counter()
|
||||||
|
content_city_counts: Counter = Counter()
|
||||||
|
|
||||||
def _payload(row: Dict[str, Any]) -> Dict[str, Any]:
|
def _payload(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
payload = row.get("payload")
|
payload = row.get("payload")
|
||||||
@@ -2756,6 +2778,19 @@ class DBManager:
|
|||||||
diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1
|
diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if raw_event_type in content_summary:
|
||||||
|
content_summary[raw_event_type]["total"] += 1
|
||||||
|
user_id = str(row.get("user_id") or "").strip().lower()
|
||||||
|
if user_id:
|
||||||
|
content_user_sets[raw_event_type].add(user_id)
|
||||||
|
content_actor_sets[raw_event_type].add(_actor_key(row))
|
||||||
|
path = str(payload.get("path") or "/").strip()[:120]
|
||||||
|
content_path_counts[path or "/"] += 1
|
||||||
|
city = str(payload.get("city") or "").strip().lower()
|
||||||
|
if city:
|
||||||
|
content_city_counts[city[:80]] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
event_type = alias_to_event.get(raw_event_type)
|
event_type = alias_to_event.get(raw_event_type)
|
||||||
if not event_type:
|
if not event_type:
|
||||||
continue
|
continue
|
||||||
@@ -2778,6 +2813,9 @@ class DBManager:
|
|||||||
for name in event_names:
|
for name in event_names:
|
||||||
summary[name]["unique_users"] = len(user_sets[name])
|
summary[name]["unique_users"] = len(user_sets[name])
|
||||||
summary[name]["unique_actors"] = len(actor_sets[name])
|
summary[name]["unique_actors"] = len(actor_sets[name])
|
||||||
|
for name in content_event_names:
|
||||||
|
content_summary[name]["unique_users"] = len(content_user_sets[name])
|
||||||
|
content_summary[name]["unique_actors"] = len(content_actor_sets[name])
|
||||||
for name in diagnostic_event_names:
|
for name in diagnostic_event_names:
|
||||||
diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name])
|
diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name])
|
||||||
diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6)
|
diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6)
|
||||||
@@ -2793,6 +2831,11 @@ class DBManager:
|
|||||||
"window_days": safe_days,
|
"window_days": safe_days,
|
||||||
"since": since_dt.isoformat(),
|
"since": since_dt.isoformat(),
|
||||||
"events": summary,
|
"events": summary,
|
||||||
|
"content_events": content_summary,
|
||||||
|
"content": {
|
||||||
|
"paths": _top(content_path_counts),
|
||||||
|
"cities": _top(content_city_counts),
|
||||||
|
},
|
||||||
"diagnostics": diagnostics,
|
"diagnostics": diagnostics,
|
||||||
"traffic": {
|
"traffic": {
|
||||||
"referrers": _top(referrer_counts),
|
"referrers": _top(referrer_counts),
|
||||||
|
|||||||
@@ -2470,18 +2470,32 @@ class PaymentContractCheckoutService:
|
|||||||
else:
|
else:
|
||||||
self._require_user_wallet(user_id, from_addr)
|
self._require_user_wallet(user_id, from_addr)
|
||||||
|
|
||||||
|
validation_pending_reasons = {
|
||||||
|
"payment_tx_validation_failed",
|
||||||
|
"tx_not_mined",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
|
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise PaymentCheckoutError(
|
if intent.payment_mode != "direct":
|
||||||
400,
|
raise PaymentCheckoutError(
|
||||||
f"payment_tx_validation_failed: {exc}",
|
400,
|
||||||
) from exc
|
f"payment_tx_validation_failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
validation = {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "payment_tx_validation_failed",
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
if not bool(validation.get("valid")):
|
if not bool(validation.get("valid")):
|
||||||
reason = str(validation.get("reason") or "payment_tx_invalid").strip()
|
reason = str(validation.get("reason") or "payment_tx_invalid").strip()
|
||||||
detail = str(validation.get("detail") or reason).strip()
|
detail = str(validation.get("detail") or reason).strip()
|
||||||
message = reason if detail == reason else f"{reason}: {detail}"
|
message = reason if detail == reason else f"{reason}: {detail}"
|
||||||
raise PaymentCheckoutError(400, message)
|
is_pending_direct_validation = (
|
||||||
|
intent.payment_mode == "direct" and reason in validation_pending_reasons
|
||||||
|
)
|
||||||
|
if not is_pending_direct_validation:
|
||||||
|
raise PaymentCheckoutError(400, message)
|
||||||
|
|
||||||
now_iso = _to_iso(now)
|
now_iso = _to_iso(now)
|
||||||
self._rest(
|
self._rest(
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
|||||||
1,
|
1,
|
||||||
)[0]
|
)[0]
|
||||||
warmer_block = compose.split(" polyweather_warmer:", 1)[1].split(
|
warmer_block = compose.split(" polyweather_warmer:", 1)[1].split(
|
||||||
|
"\n polyweather_training_settlement:",
|
||||||
|
1,
|
||||||
|
)[0]
|
||||||
|
training_settlement_block = compose.split(
|
||||||
|
" polyweather_training_settlement:",
|
||||||
|
1,
|
||||||
|
)[1].split(
|
||||||
"\nx-polyweather-base:",
|
"\nx-polyweather-base:",
|
||||||
1,
|
1,
|
||||||
)[0]
|
)[0]
|
||||||
@@ -102,6 +109,7 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
|||||||
assert "POLYWEATHER_SERVICE_ROLE: bot" in compose
|
assert "POLYWEATHER_SERVICE_ROLE: bot" in compose
|
||||||
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
|
assert "POLYWEATHER_SERVICE_ROLE: collector" in collector_block
|
||||||
assert "POLYWEATHER_SERVICE_ROLE: warmer" in warmer_block
|
assert "POLYWEATHER_SERVICE_ROLE: warmer" in warmer_block
|
||||||
|
assert "POLYWEATHER_SERVICE_ROLE: training_settlement" in training_settlement_block
|
||||||
assert "redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction" in compose
|
assert "redis-server --appendonly yes --maxmemory ${POLYWEATHER_REDIS_MAXMEMORY:-512mb} --maxmemory-policy noeviction" in compose
|
||||||
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
|
assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED: 'false'" in bot_block
|
||||||
assert "POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}" in web_block
|
assert "POLYWEATHER_EVENT_STORE: ${POLYWEATHER_EVENT_STORE:-redis}" in web_block
|
||||||
@@ -117,6 +125,18 @@ def test_docker_compose_isolates_collector_from_web_and_bot_services():
|
|||||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in web_block
|
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in web_block
|
||||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block
|
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'true'" in collector_block
|
||||||
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in warmer_block
|
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in warmer_block
|
||||||
|
assert "POLYWEATHER_OBSERVATION_COLLECTOR_ENABLED: 'false'" in training_settlement_block
|
||||||
|
assert "command: python -m web.training_settlement_worker" in training_settlement_block
|
||||||
|
assert (
|
||||||
|
"POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC: "
|
||||||
|
"${POLYWEATHER_TRAINING_SETTLEMENT_INTERVAL_SEC:-21600}"
|
||||||
|
in training_settlement_block
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS: "
|
||||||
|
"${POLYWEATHER_TRAINING_SETTLEMENT_LOOKBACK_DAYS:-10}"
|
||||||
|
in training_settlement_block
|
||||||
|
)
|
||||||
assert "POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}" in web_block
|
assert "POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_CONCURRENCY:-3}" in web_block
|
||||||
assert "POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-3}" in web_block
|
assert "POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY: ${POLYWEATHER_CITY_DETAIL_BATCH_GLOBAL_CONCURRENCY:-3}" in web_block
|
||||||
assert "POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}" in web_block
|
assert "POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS: ${POLYWEATHER_CITY_DETAIL_BATCH_QUEUE_WAIT_MS:-3000}" in web_block
|
||||||
@@ -287,6 +307,7 @@ def test_deploy_script_retries_compose_recreate_races():
|
|||||||
assert 'compose_up_retry "backend services" -d --no-deps polyweather_web polyweather' in script
|
assert 'compose_up_retry "backend services" -d --no-deps polyweather_web polyweather' in script
|
||||||
assert 'compose_up_retry "observation collector" -d --no-deps polyweather_collector' in script
|
assert 'compose_up_retry "observation collector" -d --no-deps polyweather_collector' in script
|
||||||
assert 'compose_up_retry "cache warmer" -d --no-deps polyweather_warmer' in script
|
assert 'compose_up_retry "cache warmer" -d --no-deps polyweather_warmer' in script
|
||||||
|
assert 'compose_up_retry "training settlement" -d --no-deps polyweather_training_settlement' in script
|
||||||
assert 'compose_up_retry "frontend" -d --no-deps polyweather_frontend' in script
|
assert 'compose_up_retry "frontend" -d --no-deps polyweather_frontend' in script
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -416,10 +416,12 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
|
|||||||
assert submitted["tx_hash"] == "0x" + "2" * 64
|
assert submitted["tx_hash"] == "0x" + "2" * 64
|
||||||
|
|
||||||
|
|
||||||
def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path):
|
def test_direct_submit_accepts_pending_validation_for_background_confirm(monkeypatch, tmp_path):
|
||||||
_setup_env(monkeypatch, tmp_path)
|
_setup_env(monkeypatch, tmp_path)
|
||||||
service = PaymentContractCheckoutService()
|
service = PaymentContractCheckoutService()
|
||||||
tx_hash = "0x" + "7" * 64
|
tx_hash = "0x" + "7" * 64
|
||||||
|
submitted = {}
|
||||||
|
transactions = []
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service,
|
service,
|
||||||
@@ -458,17 +460,76 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
|
|||||||
return []
|
return []
|
||||||
if method == "GET" and table == "payment_intents":
|
if method == "GET" and table == "payment_intents":
|
||||||
return []
|
return []
|
||||||
raise AssertionError(f"submit must not mutate {table} before validation passes")
|
if method == "PATCH" and table == "payment_intents":
|
||||||
|
submitted.update(kwargs["payload"])
|
||||||
|
return [{"ok": True}]
|
||||||
|
if method == "POST" and table == "payment_transactions":
|
||||||
|
transactions.append(kwargs["payload"])
|
||||||
|
return [kwargs["payload"]]
|
||||||
|
raise AssertionError((method, table, kwargs))
|
||||||
|
|
||||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||||
|
|
||||||
try:
|
result = service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
|
||||||
service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
|
|
||||||
except Exception as exc:
|
assert result["status"] == "submitted"
|
||||||
assert getattr(exc, "status_code", None) == 400
|
assert submitted["status"] == "submitted"
|
||||||
assert "tx_not_mined" in getattr(exc, "detail", "")
|
assert submitted["tx_hash"] == tx_hash
|
||||||
else:
|
assert transactions[0]["status"] == "submitted"
|
||||||
raise AssertionError("expected tx_not_mined rejection")
|
|
||||||
|
|
||||||
|
def test_direct_submit_accepts_validation_exception_for_background_confirm(monkeypatch, tmp_path):
|
||||||
|
_setup_env(monkeypatch, tmp_path)
|
||||||
|
service = PaymentContractCheckoutService()
|
||||||
|
tx_hash = "0x" + "a" * 64
|
||||||
|
submitted = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"get_intent",
|
||||||
|
lambda user_id, intent_id: service._serialize_intent(
|
||||||
|
{
|
||||||
|
"id": intent_id,
|
||||||
|
"plan_code": "pro_monthly",
|
||||||
|
"plan_id": 101,
|
||||||
|
"chain_id": 137,
|
||||||
|
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||||
|
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||||
|
"amount_units": "5000000",
|
||||||
|
"payment_mode": "direct",
|
||||||
|
"allowed_wallet": None,
|
||||||
|
"order_id_hex": "0x" + "1" * 64,
|
||||||
|
"status": "created",
|
||||||
|
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||||
|
"tx_hash": None,
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_validation(*args, **kwargs):
|
||||||
|
raise RuntimeError("rpc timeout")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_validate_loaded_intent_tx", fail_validation)
|
||||||
|
|
||||||
|
def fake_rest(method, table, **kwargs):
|
||||||
|
if method == "GET" and table == "payment_transactions":
|
||||||
|
return []
|
||||||
|
if method == "GET" and table == "payment_intents":
|
||||||
|
return []
|
||||||
|
if method == "PATCH" and table == "payment_intents":
|
||||||
|
submitted.update(kwargs["payload"])
|
||||||
|
return [{"ok": True}]
|
||||||
|
if method == "POST" and table == "payment_transactions":
|
||||||
|
return [kwargs["payload"]]
|
||||||
|
raise AssertionError((method, table, kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||||
|
|
||||||
|
result = service.submit_intent_tx("user-1", "intent-direct-rpc-pending", tx_hash, "")
|
||||||
|
|
||||||
|
assert result["status"] == "submitted"
|
||||||
|
assert submitted["tx_hash"] == tx_hash
|
||||||
|
|
||||||
|
|
||||||
def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path):
|
def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path):
|
||||||
|
|||||||
@@ -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 datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
@@ -9,13 +10,16 @@ import web.services.auth_api as auth_api
|
|||||||
from web.app import app
|
from web.app import app
|
||||||
import web.routes as routes
|
import web.routes as routes
|
||||||
import web.services.ops_api as ops_api
|
import web.services.ops_api as ops_api
|
||||||
|
import web.diagnostics.health as diagnostics_health
|
||||||
import web.scan_terminal_cache as scan_terminal_cache
|
import web.scan_terminal_cache as scan_terminal_cache
|
||||||
import web.scan_terminal_service as scan_terminal_service
|
import web.scan_terminal_service as scan_terminal_service
|
||||||
|
import web.services.system_api as system_api
|
||||||
import web.services.city_api as city_api
|
import web.services.city_api as city_api
|
||||||
import web.services.city_runtime as city_runtime
|
import web.services.city_runtime as city_runtime
|
||||||
from web.services.observation_freshness import build_observation_freshness
|
from web.services.observation_freshness import build_observation_freshness
|
||||||
from web.scan_terminal_cache import scan_terminal_cache_key
|
from web.scan_terminal_cache import scan_terminal_cache_key
|
||||||
from src.database.runtime_state import TruthRecordRepository
|
from src.database.runtime_state import RuntimeStateDB, TruthRecordRepository
|
||||||
|
from src.utils.metrics import export_prometheus_metrics
|
||||||
|
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
@@ -83,6 +87,8 @@ def test_system_status_returns_summary_shape_for_ops_admin(monkeypatch):
|
|||||||
assert 'sse_connections' in payload['realtime']
|
assert 'sse_connections' in payload['realtime']
|
||||||
assert 'truth_records' in payload['training_data']
|
assert 'truth_records' in payload['training_data']
|
||||||
assert 'training_features' in payload['training_data']
|
assert 'training_features' in payload['training_data']
|
||||||
|
assert 'stale_days' in payload['training_data']['truth_records']
|
||||||
|
assert 'stale_days' in payload['training_data']['training_features']
|
||||||
assert 'city_coverage' in payload['training_data']
|
assert 'city_coverage' in payload['training_data']
|
||||||
assert 'model_city_coverage' in payload['training_data']
|
assert 'model_city_coverage' in payload['training_data']
|
||||||
assert 'metar_entries' in payload['cache']
|
assert 'metar_entries' in payload['cache']
|
||||||
@@ -121,6 +127,85 @@ def test_metrics_endpoint_returns_prometheus_payload_for_ops_admin(monkeypatch):
|
|||||||
assert 'polyweather_http_requests_total' in response.text
|
assert 'polyweather_http_requests_total' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_data_summary_reports_stale_days(tmp_path):
|
||||||
|
db = RuntimeStateDB(str(tmp_path / "training.db"))
|
||||||
|
yesterday = (datetime.now(timezone.utc).date() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
stale_day = (datetime.now(timezone.utc).date() - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO truth_records_store (
|
||||||
|
city, target_date, actual_high, settlement_source, updated_at, is_final
|
||||||
|
) VALUES ('shanghai', ?, 31.2, 'metar', 1, 1)
|
||||||
|
""",
|
||||||
|
(yesterday,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_feature_records_store (
|
||||||
|
city, target_date, updated_at, payload_json
|
||||||
|
) VALUES ('shanghai', ?, 1, '{}')
|
||||||
|
""",
|
||||||
|
(stale_day,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
payload = diagnostics_health._training_data_summary(
|
||||||
|
SimpleNamespace(db_path=db.db_path),
|
||||||
|
{"shanghai": {"name": "Shanghai", "settlement_source": "metar", "icao": "ZSSS"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["truth_records"]["stale_days"] == 1
|
||||||
|
assert payload["training_features"]["stale_days"] == 5
|
||||||
|
assert payload["stale"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_prometheus_exports_training_data_stale_metrics(monkeypatch, tmp_path):
|
||||||
|
db = RuntimeStateDB(str(tmp_path / "training-metrics.db"))
|
||||||
|
stale_day = (datetime.now(timezone.utc).date() - timedelta(days=4)).strftime("%Y-%m-%d")
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_records_store (
|
||||||
|
city, target_date, actual_high, deb_prediction, mu, updated_at, payload_json
|
||||||
|
) VALUES ('shanghai', ?, 31.2, 31.0, 31.1, 1, '{}')
|
||||||
|
""",
|
||||||
|
(stale_day,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO truth_records_store (
|
||||||
|
city, target_date, actual_high, settlement_source, updated_at, is_final
|
||||||
|
) VALUES ('shanghai', ?, 31.2, 'metar', 1, 1)
|
||||||
|
""",
|
||||||
|
(stale_day,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO training_feature_records_store (
|
||||||
|
city, target_date, updated_at, payload_json
|
||||||
|
) VALUES ('shanghai', ?, 1, '{}')
|
||||||
|
""",
|
||||||
|
(stale_day,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
fake_db = SimpleNamespace(
|
||||||
|
db_path=db.db_path,
|
||||||
|
list_payment_audit_events=lambda **_kwargs: [],
|
||||||
|
list_refund_cases=lambda **_kwargs: [],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(system_api, "DBManager", lambda: fake_db)
|
||||||
|
|
||||||
|
system_api._refresh_operational_metrics()
|
||||||
|
metrics = export_prometheus_metrics()
|
||||||
|
|
||||||
|
assert "polyweather_daily_records_stale_days 4" in metrics
|
||||||
|
assert "polyweather_truth_records_stale_days 4" in metrics
|
||||||
|
assert "polyweather_training_features_stale_days 4" in metrics
|
||||||
|
assert "polyweather_training_data_stale 1" in metrics
|
||||||
|
|
||||||
|
|
||||||
def test_system_cache_status_requires_ops_admin(monkeypatch):
|
def test_system_cache_status_requires_ops_admin(monkeypatch):
|
||||||
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
|
||||||
|
|
||||||
@@ -202,6 +287,10 @@ def test_standard_growth_funnel_events_are_trackable():
|
|||||||
"payment_start",
|
"payment_start",
|
||||||
"payment_success",
|
"payment_success",
|
||||||
"degraded_auth_profile",
|
"degraded_auth_profile",
|
||||||
|
"brief_view",
|
||||||
|
"brief_cta_click",
|
||||||
|
"methodology_view",
|
||||||
|
"social_outbound_click",
|
||||||
}.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS)
|
}.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS)
|
||||||
|
|
||||||
|
|
||||||
@@ -287,6 +376,61 @@ def test_growth_funnel_summarizes_traffic_sources(monkeypatch):
|
|||||||
assert {"name": "mobile", "count": 1} in summary["traffic"]["devices"]
|
assert {"name": "mobile", "count": 1} in summary["traffic"]["devices"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_growth_funnel_summarizes_public_content_events(monkeypatch):
|
||||||
|
from src.database.db_manager import DBManager
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"event_type": "brief_view",
|
||||||
|
"user_id": "",
|
||||||
|
"client_id": "c1",
|
||||||
|
"session_id": "s1",
|
||||||
|
"payload": {"path": "/briefs/ankara/2026-06-24", "city": "ankara"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"event_type": "brief_cta_click",
|
||||||
|
"user_id": "",
|
||||||
|
"client_id": "c1",
|
||||||
|
"session_id": "s1",
|
||||||
|
"payload": {"path": "/briefs/ankara/2026-06-24", "cta": "terminal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"event_type": "methodology_view",
|
||||||
|
"user_id": "",
|
||||||
|
"client_id": "c2",
|
||||||
|
"session_id": "s2",
|
||||||
|
"payload": {"path": "/methodology/deb", "slug": "deb"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"event_type": "social_outbound_click",
|
||||||
|
"user_id": "",
|
||||||
|
"client_id": "c3",
|
||||||
|
"session_id": "s3",
|
||||||
|
"payload": {"path": "/briefs/ankara/2026-06-24", "destination": "x_intent"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DBManager,
|
||||||
|
"list_app_analytics_events",
|
||||||
|
lambda self, limit=20000, since_iso=None: rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = DBManager().get_app_analytics_funnel_summary(days=7)
|
||||||
|
|
||||||
|
assert summary["content_events"]["brief_view"]["total"] == 1
|
||||||
|
assert summary["content_events"]["brief_cta_click"]["unique_actors"] == 1
|
||||||
|
assert summary["content_events"]["methodology_view"]["total"] == 1
|
||||||
|
assert summary["content_events"]["social_outbound_click"]["total"] == 1
|
||||||
|
assert summary["content"]["paths"][0] == {
|
||||||
|
"name": "/briefs/ankara/2026-06-24",
|
||||||
|
"count": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_ops_source_health_flags_expected_official_sources(monkeypatch):
|
def test_ops_source_health_flags_expected_official_sources(monkeypatch):
|
||||||
class FakeCache:
|
class FakeCache:
|
||||||
def get_city_cache(self, kind, city):
|
def get_city_cache(self, kind, city):
|
||||||
|
|||||||
@@ -133,15 +133,28 @@ def _table_date_summary(conn, table_name: str) -> Dict[str, Any]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "error": str(exc), "row_count": 0, "cities_count": 0}
|
return {"ok": False, "error": str(exc), "row_count": 0, "cities_count": 0}
|
||||||
|
|
||||||
|
max_date = row["max_date"]
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"row_count": int(row["row_count"] or 0),
|
"row_count": int(row["row_count"] or 0),
|
||||||
"cities_count": int(row["cities_count"] or 0),
|
"cities_count": int(row["cities_count"] or 0),
|
||||||
"min_date": row["min_date"],
|
"min_date": row["min_date"],
|
||||||
"max_date": row["max_date"],
|
"max_date": max_date,
|
||||||
|
"stale_days": _target_date_stale_days(max_date),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _target_date_stale_days(target_date: Optional[str]) -> Optional[int]:
|
||||||
|
raw = str(target_date or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(raw, "%Y-%m-%d").date()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return max(0, (datetime.now(timezone.utc).date() - parsed).days)
|
||||||
|
|
||||||
|
|
||||||
def _truth_source_counts(conn) -> Dict[str, int]:
|
def _truth_source_counts(conn) -> Dict[str, int]:
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -293,11 +306,17 @@ def _training_data_summary(account_db, city_registry) -> Dict[str, Any]:
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
db_path = account_db.db_path
|
db_path = account_db.db_path
|
||||||
|
daily_records = {"ok": False, "row_count": 0, "cities_count": 0}
|
||||||
truth_records = {"ok": False, "row_count": 0, "cities_count": 0}
|
truth_records = {"ok": False, "row_count": 0, "cities_count": 0}
|
||||||
truth_revisions = {"ok": False, "row_count": 0}
|
truth_revisions = {"ok": False, "row_count": 0}
|
||||||
training_features = {"ok": False, "row_count": 0, "cities_count": 0}
|
training_features = {"ok": False, "row_count": 0, "cities_count": 0}
|
||||||
|
stale_threshold_days = max(
|
||||||
|
0,
|
||||||
|
int(os.getenv("POLYWEATHER_TRAINING_DATA_STALE_WARN_DAYS", "2") or "2"),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with connect_sqlite(db_path, row_factory=sqlite3.Row) as conn:
|
with connect_sqlite(db_path, row_factory=sqlite3.Row) as conn:
|
||||||
|
daily_records = _table_date_summary(conn, "daily_records_store")
|
||||||
truth_records = _table_date_summary(conn, "truth_records_store")
|
truth_records = _table_date_summary(conn, "truth_records_store")
|
||||||
if truth_records.get("ok"):
|
if truth_records.get("ok"):
|
||||||
truth_records["source_counts"] = _truth_source_counts(conn)
|
truth_records["source_counts"] = _truth_source_counts(conn)
|
||||||
@@ -311,19 +330,36 @@ def _training_data_summary(account_db, city_registry) -> Dict[str, Any]:
|
|||||||
"db_path": db_path,
|
"db_path": db_path,
|
||||||
"db_ok": False,
|
"db_ok": False,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
|
"daily_records": daily_records,
|
||||||
"truth_records": truth_records,
|
"truth_records": truth_records,
|
||||||
"truth_revisions": truth_revisions,
|
"truth_revisions": truth_revisions,
|
||||||
"training_features": training_features,
|
"training_features": training_features,
|
||||||
|
"stale": True,
|
||||||
|
"stale_threshold_days": stale_threshold_days,
|
||||||
"city_coverage": {},
|
"city_coverage": {},
|
||||||
"model_city_coverage": {},
|
"model_city_coverage": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stale_days = [
|
||||||
|
value
|
||||||
|
for value in (
|
||||||
|
daily_records.get("stale_days"),
|
||||||
|
truth_records.get("stale_days"),
|
||||||
|
training_features.get("stale_days"),
|
||||||
|
)
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"db_path": db_path,
|
"db_path": db_path,
|
||||||
"db_ok": True,
|
"db_ok": True,
|
||||||
|
"daily_records": daily_records,
|
||||||
"truth_records": truth_records,
|
"truth_records": truth_records,
|
||||||
"truth_revisions": truth_revisions,
|
"truth_revisions": truth_revisions,
|
||||||
"training_features": training_features,
|
"training_features": training_features,
|
||||||
|
"stale": any(int(value) > stale_threshold_days for value in stale_days)
|
||||||
|
if stale_days
|
||||||
|
else True,
|
||||||
|
"stale_threshold_days": stale_threshold_days,
|
||||||
"city_coverage": city_coverage,
|
"city_coverage": city_coverage,
|
||||||
"model_city_coverage": _model_city_coverage_summary(
|
"model_city_coverage": _model_city_coverage_summary(
|
||||||
city_coverage.get("entries") or [],
|
city_coverage.get("entries") or [],
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ TRACKABLE_ANALYTICS_EVENTS = {
|
|||||||
"paywall_viewed",
|
"paywall_viewed",
|
||||||
"checkout_started",
|
"checkout_started",
|
||||||
"checkout_succeeded",
|
"checkout_succeeded",
|
||||||
|
"brief_view",
|
||||||
|
"brief_cta_click",
|
||||||
|
"methodology_view",
|
||||||
|
"social_outbound_click",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_STATUS_CITIES = [
|
DEFAULT_STATUS_CITIES = [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, Request
|
from fastapi import BackgroundTasks, Request
|
||||||
@@ -13,6 +14,7 @@ from fastapi.responses import PlainTextResponse
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from src.database.db_manager import DBManager
|
from src.database.db_manager import DBManager
|
||||||
|
from src.database.sqlite_connection import connect_sqlite
|
||||||
from src.utils.metrics import export_prometheus_metrics, gauge_set
|
from src.utils.metrics import export_prometheus_metrics, gauge_set
|
||||||
from web.core import build_health_payload, build_system_status_payload
|
from web.core import build_health_payload, build_system_status_payload
|
||||||
import web.routes as legacy_routes
|
import web.routes as legacy_routes
|
||||||
@@ -211,3 +213,51 @@ def _refresh_operational_metrics() -> None:
|
|||||||
gauge_set("polyweather_sqlite_db_size_bytes", os.path.getsize(db_path))
|
gauge_set("polyweather_sqlite_db_size_bytes", os.path.getsize(db_path))
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
_refresh_training_data_metrics(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_date_stale_days(target_date: Optional[str]) -> Optional[int]:
|
||||||
|
raw = str(target_date or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(raw, "%Y-%m-%d").date()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return max(0, (datetime.now(timezone.utc).date() - parsed).days)
|
||||||
|
|
||||||
|
|
||||||
|
def _max_target_date(db_path: str, table_name: str) -> Optional[str]:
|
||||||
|
with connect_sqlite(db_path) as conn:
|
||||||
|
row = conn.execute(f"SELECT MAX(target_date) AS max_date FROM {table_name}").fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_training_data_metrics(db_path: str) -> None:
|
||||||
|
stale_threshold_days = max(
|
||||||
|
0,
|
||||||
|
int(os.getenv("POLYWEATHER_TRAINING_DATA_STALE_WARN_DAYS", "2") or "2"),
|
||||||
|
)
|
||||||
|
table_metrics = {
|
||||||
|
"daily_records_store": "polyweather_daily_records_stale_days",
|
||||||
|
"truth_records_store": "polyweather_truth_records_stale_days",
|
||||||
|
"training_feature_records_store": "polyweather_training_features_stale_days",
|
||||||
|
}
|
||||||
|
max_stale_days: Optional[int] = None
|
||||||
|
for table_name, metric_name in table_metrics.items():
|
||||||
|
try:
|
||||||
|
stale_days = _target_date_stale_days(_max_target_date(db_path, table_name))
|
||||||
|
except Exception:
|
||||||
|
stale_days = None
|
||||||
|
if stale_days is None:
|
||||||
|
gauge_set(metric_name, -1)
|
||||||
|
max_stale_days = max(max_stale_days or 0, stale_threshold_days + 1)
|
||||||
|
continue
|
||||||
|
gauge_set(metric_name, stale_days)
|
||||||
|
max_stale_days = max(max_stale_days or 0, stale_days)
|
||||||
|
gauge_set(
|
||||||
|
"polyweather_training_data_stale",
|
||||||
|
1 if max_stale_days is None or max_stale_days > stale_threshold_days else 0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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